factrix.by_slice ¶
by_slice(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, price_data: DataInput | None = None, forward_periods: int | None = None, overlap_periods: int | None = None, strict: bool = True, expected_warnings: tuple[str, ...] = ()) -> dict[str, EvaluationResult]
Partition data by by and run :func:factrix.evaluate per slice.
by_slice is the cross-slice counterpart of :func:factrix.evaluate:
it partitions a raw panel on a column, evaluates metric on each
slice independently (the full producer→consumer DAG runs per
slice, so DAG-consumer metrics work with no pre-computation), and
returns the per-slice results for comparison. It does no cross-slice
statistical inference; for paired / omnibus contrasts see
:func:factrix.slice_pairwise_test / :func:factrix.slice_joint_test.
Each slice is evaluated as an independent dataset — it sees only its
own rows. For cross-sectional partitions (sector, size bucket;
the partition value is constant within an asset) this is exactly the
intent: each slice is an independent universe with intact per-asset
history. For date-axis partitions (year, regime; the value varies
within an asset over time) a metric whose aggregation looks across
dates — rolling-window betas, per-asset time-series regressions, event
windows (common_beta, mfe_mae, oos_decay, …) — sees truncated
history at slice boundaries, so its per-slice value differs from the
full-sample value decomposed by period. Per-date metrics (ic,
fm_beta, quantile) are unaffected. A
:class:~factrix._codes.WarningCode.SLICE_BOUNDARY_TRUNCATION warning
is emitted when a cross-date metric is sliced on a date axis.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
DataFrame
|
Raw long-format panel — same input contract as
:func: |
required |
metric
|
MetricBase
|
A metric instance from :mod: |
required |
by
|
str
|
Column name in |
required |
factor_col
|
str
|
The factor column to evaluate. Single-factor by
design — multi-factor / multi-metric batching is the job of
:func: |
required |
price_data
|
DataInput | None
|
Optional complete The restriction is always by asset, and on a date-axis
partition also by the slice's own period range. Forwarding the
panel whole would hand each slice quantities formed outside it —
the unconditional baseline |
None
|
forward_periods
|
int | None
|
The data's return horizon, forwarded to
|
None
|
overlap_periods
|
int | None
|
The evaluation-grid overlap, forwarded to
|
None
|
strict
|
bool
|
Forwarded to |
True
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
Returns:
| Type | Description |
|---|---|
dict[str, EvaluationResult]
|
|
dict[str, EvaluationResult]
|
func: |
dict[str, EvaluationResult]
|
|
dict[str, EvaluationResult]
|
Inside each bundle the metric is keyed by its **registered spec |
dict[str, EvaluationResult]
|
name** ( |
dict[str, EvaluationResult]
|
|
dict[str, EvaluationResult]
|
Iteration order matches polars |
dict[str, EvaluationResult]
|
a cross-slice comparison table, stack the per-slice frames with |
dict[str, EvaluationResult]
|
the standard |
dict[str, EvaluationResult]
|
with its slice key:: pl.concat([ r.to_frame().with_columns(pl.lit(k).alias("slice")) for k, r in result.items() ]) |
dict[str, EvaluationResult]
|
No cross-slice statistical inference — see API page. |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
ValueError
|
|
UserInputError
|
|
Examples:
Per-sector information coefficient (IC) on a synthetic
cross-sectional panel — partition on a sector column, evaluate
ic independently within each sector:
>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic
>>> raw = fx.datasets.make_cs_panel(n_assets=100, n_dates=250)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> assets = panel["asset_id"].unique().sort().to_list()
>>> sector = {a: ("tech" if i % 2 else "fin")
... for i, a in enumerate(assets)}
>>> panel = panel.with_columns(
... pl.col("asset_id").replace_strict(sector).alias("sector")
... )
>>> per_sector = fx.by_slice(panel, ic(), by="sector", factor_col="factor")
>>> set(per_sector) == {"tech", "fin"}
True
Cross-slice research dispatcher — the partitioned counterpart of
evaluate. by_slice partitions an evaluate-ready panel —
the same (date, asset_id, factor, forward_return) input evaluate takes, so
forward_return must already be attached — on a column already present in it
and runs the standard evaluate pipeline
independently on each slice, returning the same
dict[str, EvaluationResult] shape as evaluate (keyed by slice value
rather than factor).
The axis name does not bake into the API — market, sector, regime, market-cap tier, ADV bucket all share the same dispatcher.
Argument contract¶
by_slice(data, metric, *, by, factor_col, price_data=None, forward_periods=None, overlap_periods=None, strict=True, expected_warnings=()):
data— a raw long-format panel, the same input contract asevaluate(date, asset_id, <factor_col>, forward_return), with the slicing columnbyalready present.metric— a metric instance (ic(),caar()), consistent withevaluate(metrics={...}). The bare class (ic) is rejected.by— the partition column.factor_col— the single factor to evaluate (multi-factor batching is the job ofevaluate).price_data— an optional complete(date, asset_id, price)panel, forwarded to every slice for event offsets and excursion windows, restricted to that slice's own assets. The sliceddatapanel still owns event eligibility and the forward-return sample, so price-only rows never enter return metrics or sample floors.
When price_data is supplied, offsets= and window= count periods on its
price grid, not on the (possibly coarser or tail-truncated) evaluation grid.
This is the same period-unit contract as
evaluate.
The forwarded panel is restricted to the sample the slice is drawn from,
because the quantities a metric forms from prices are formed over whatever
that panel spans. They are not hypothetical: event_around_return subtracts
per-asset unconditional baselines read off the price panel, and the ragged-grid
warning describes it. Forwarding the panel whole would form both outside the
slice, so passing prices would silently change what the slice is measured
against.
The restriction is always by asset, and on a date-axis partition also by the slice's own period range:
| Partition | Example by |
What is restricted | Effect |
|---|---|---|---|
| Cross-sectional (constant within an asset) | sector, size_bucket |
Assets only — every slice spans the whole period range anyway | A path crossing a slice boundary in time is still walked, and the tail compute_forward_return dropped stays reachable |
| Date-axis (varies within an asset over time) | regime, calendar_year |
Assets and the slice's first-to-last period | A regime is benchmarked against its own drift, not its neighbours' |
The asset restriction alone is a no-op on a date-axis partition — every slice
holds every asset — so without the period range a regime would read the rest
of the span's trend as event alpha. The period restriction is the same
own-periods contract slice_boundary_truncation already declares for these
partitions. The range is first-to-last, not the exact period set, so periods
the evaluation grid skipped inside the slice still complete the price grid.
For example, preserve a tail needed by an event offset without putting that tail back into the return sample:
per_sector = by_slice(
panel,
event_around_return(offsets=[24]),
by="sector",
factor_col="event_factor",
price_data=raw,
strict=False,
)
The producer→consumer DAG runs per slice, so DAG-consumer metrics
(ic, caar, fm_beta, …) work with no pre-computation — exactly as
they do under evaluate.
Mental model: "evaluate, partitioned"¶
Each slice is evaluated as an independent dataset — it sees only its own rows. The consequence depends on the slicing axis:
- Cross-sectional partition (sector, size bucket; the value is constant within an asset): each slice is an independent universe with intact per-asset history. This is the primary intent.
- Date-axis partition (year, regime; the value varies within an asset
over time): a metric declaring boundary sensitivity —
rolling-window betas, per-asset time-series regressions, event windows
(
common_beta,mfe_mae,oos_decay) — sees truncated history at slice boundaries, so its per-slice value differs from the full-sample value decomposed by period. Per-date metrics (ic,fm_beta,quantile) are unaffected.positive_rateis sensitive because each slice resets its non-overlap sampling phase.
by_slice emits a WarningCode.SLICE_BOUNDARY_TRUNCATION warning when a
metric whose MetricSpec.slice_boundary_sensitive capability is true is
sliced on a date axis. The condition is a property of the (metric, by)
pair, so it applies to every slice: alongside the Python UserWarning
echo, the record is attached to each slice's
EvaluationResult.warnings (sourced on the metric name), so it is
readable programmatically like any other bundle warning. If you want the
full-sample metric decomposed by period instead, compute it once on the
whole panel and group the per-date output yourself.
result = by_slice(panel, positive_rate(), by="year", factor_col="factor")
[w.code.value for w in result["2024"].warnings]
# ['slice_boundary_truncation']
If by is not in data.columns, by_slice raises UserInputError
naming by and listing the frame's own columns as candidates — compose
the column upstream with data.with_columns(...) or data.join(...).
An empty frame and a by column carrying nulls are rejected the same
way.
Cross-slice comparison table¶
by_slice returns a plain dict[str, EvaluationResult] (the same shape
as evaluate), keyed by slice value. Inside each bundle the metric is
keyed by its registered spec name — by_slice(panel, ic(), ...)
yields result["tech"].metrics["ic"] — so metric_name in a stacked
frame identifies the metric and two by_slice runs stack unambiguously.
The standard EvaluationResult.to_frame stacking idiom builds a
comparison table — tag each row with its slice key:
result = by_slice(panel, ic(), by="sector", factor_col="factor")
pl.concat([
r.to_frame().with_columns(pl.lit(k).alias("slice"))
for k, r in result.items()
]).sort("slice")
# columns include: slice, factor, n_assets, metric_name, value, p_value, alternative, stat, n_obs, warning_codes
Each EvaluationResult carries per-slice n_periods / n_assets, so
sample-size differences across slices are visible directly.
p_value is per-slice, not cross-slice-adjusted
Each slice's p_value tests that slice alone against its own null
(e.g. ic mean = 0). Filtering across K parallel slices inflates the
family-wise error rate (FWER) — under independent nulls, K=10 sectors
gives about a 40% chance of at least one false positive. by_slice is for
exploration; for inference claims with FWER / false discovery rate
(FDR) control, use
slice_pairwise_test
(Holm / Romano-Wolf / Bonferroni) or
slice_joint_test
(omnibus χ²).
What it does not do¶
by_slice performs no cross-slice statistical inference. It returns
the per-slice results and stops. Per-slice t-stats / SE are computed on
that slice alone (different n_periods / n_assets, different autocorrelation structure per
slice) and are not directly comparable — picking the top slice by
t-stat is not a defensible selection rule. A generic cross-slice test
(Benjamini-Hochberg-Yekutieli (BHY) adjustment, Sharpe-diff Wald,
paired-difference Newey-West (NW), etc.) cannot be applied honestly across
the metric matrix — the appropriate test depends on the metric family.
For metrics that expose a per_date_series capability (ic, fm_beta,
positive_rate),
slice_pairwise_test /
slice_joint_test provide
cross-slice contrasts with joint-heteroskedasticity-and-autocorrelation-consistent
(HAC) or block-bootstrap inference.
Universe overlap reference patterns¶
by_slice only partitions on a single column's distinct values. Any
overlapping-universe scenario — same row needs to count toward
multiple slices — is composed with three lines of polars upstream.
The shared idiom: filter + with_columns(by=...) per target
slice, then pl.concat.
1. Superset (subset and full set side-by-side)¶
market is "TWSE" / "OTC"; you want three slices: 上市, 上櫃, 全市場
(every row also belongs to 全市場).
import polars as pl
mapping = {"TWSE": "上市", "OTC": "上櫃"}
expanded = pl.concat([
panel.with_columns(pl.col("market").replace(mapping).alias("uni")),
panel.with_columns(pl.lit("全市場").alias("uni")),
])
by_slice(expanded, ic(), by="uni", factor_col="factor")
2. Multi-membership (one stock in multiple indices)¶
Each index membership is a separate boolean column; the same row may
have several True values.
expanded = pl.concat([
panel.filter(pl.col("in_sp500")).with_columns(pl.lit("SP500").alias("uni")),
panel.filter(pl.col("in_nasdaq100")).with_columns(pl.lit("N100").alias("uni")),
])
by_slice(expanded, ic(), by="uni", factor_col="factor")
3. Hierarchical nesting (Top-10 ⊂ Top-50 ⊂ LargeCap ⊂ All)¶
Nested by market-cap rank; each tier contains every smaller tier.
tiers = [(10, "Top10"), (50, "Top50"), (200, "LargeCap")]
expanded = pl.concat([
*[
panel.filter(pl.col("market_cap_rank") <= cutoff)
.with_columns(pl.lit(name).alias("tier"))
for cutoff, name in tiers
],
panel.with_columns(pl.lit("All").alias("tier")),
])
by_slice(expanded, ic(), by="tier", factor_col="factor")
4. Sliding window (overlapping ADV buckets)¶
Adjacent deciles overlap to smooth boundary noise:
windows = [(0, 30), (10, 40), (20, 50), (30, 60),
(40, 70), (50, 80), (60, 90), (70, 100)]
expanded = pl.concat([
panel.filter((pl.col("adv_pct") >= lo) & (pl.col("adv_pct") < hi))
.with_columns(pl.lit(f"W[{lo},{hi})").alias("adv_win"))
for lo, hi in windows
])
by_slice(expanded, ic(), by="adv_win", factor_col="factor")
5. Cross-product / multi-axis (universe × sector)¶
Not an overlap problem — by only takes a single column. Compose a
composite column with pl.concat_str:
panel = panel.with_columns(
pl.concat_str(["market", "sector"], separator="-").alias("uni_sec")
)
by_slice(panel, ic(), by="uni_sec", factor_col="factor")
# keys: "TWSE-Tech", "TWSE-Finance", "OTC-Tech", ...
The API does not accept by: list[str]: single-column vs
multi-column semantics would diverge on output dict key type
(str vs tuple), breaking the dict[str, EvaluationResult] convention
downstream.
General template¶
Cases 1–4 share one idiom — per-target-slice filter +
with_columns(by=...), then pl.concat. Overlapping rows are
duplicated naturally by the concat.