Skip to content

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:factrix.evaluate (date, asset_id, <factor_col>, forward_return; forward_return already attached via :func:factrix.preprocess.compute_forward_return). Must contain by as a column; compose it upstream if needed (data.with_columns(...) or a join).

required
metric MetricBase

A metric instance from :mod:factrix.metrics (e.g. ic(), caar()), consistent with :func:factrix.evaluate. The bare class (ic) is rejected.

required
by str

Column name in data whose distinct values define the slices. For cross-product slicing (e.g. market × sector) compose a single composite column upstream (pl.concat_str([...]).alias("...")).

required
factor_col str

The factor column to evaluate. Single-factor by design — multi-factor / multi-metric batching is the job of :func:factrix.evaluate.

required
price_data DataInput | None

Optional complete date, asset_id, price panel, forwarded to every per-slice evaluate call restricted to the sample that slice is drawn from. The slice's data rows remain the sole owner of event eligibility and the forward-return sample; this side panel only supplies the complete price grid for event offsets and excursion windows. Consequently, offsets= and window= count periods on this price grid when it is supplied, just as under :func:factrix.evaluate.

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 event_around_return subtracts, and the raggedness verdict its warning reports — so passing prices would silently change what the slice is measured against. On a cross-sectional partition (sector, size bucket) the period range is the whole panel's, so only the asset restriction bites and the tail compute_forward_return dropped stays reachable: a path crossing a slice boundary in time is still walked. On a date-axis partition (regime, calendar period) every slice holds every asset, so the period restriction is what keeps a regime from being benchmarked against its neighbours' drift — the same own-periods contract :data:~factrix._codes.WarningCode.SLICE_BOUNDARY_TRUNCATION already declares there.

None
forward_periods int | None

The data's return horizon, forwarded to evaluate on every per-slice call. Normally omitted — it is read from the panel's compute_forward_return stamp (which survives partitioning). Pass it only to declare the horizon for a self-attached forward_return panel that carries no stamp.

None
overlap_periods int | None

The evaluation-grid overlap, forwarded to evaluate alongside forward_periods with the same stamp-first contract; for an unstamped panel it defaults to the horizon.

None
strict bool

Forwarded to evaluate. True (default) raises if the metric is inapplicable to a slice; False surfaces a NaN result with a warning.

True
expected_warnings tuple[str, ...]

:class:~factrix.WarningCode values declaring warning regimes that are the study's design — the same contract as :func:factrix.evaluate, and forwarded to it for every slice. Also covers this function's own slice_boundary_truncation record: declared codes are kept on every slice's warnings with expected=True and only the UserWarning echo stops. Unknown codes are rejected.

()

Returns:

Type Description
dict[str, EvaluationResult]

dict[str, EvaluationResult] — the same shape as

dict[str, EvaluationResult]

func:factrix.evaluate, keyed by stringified slice value (an

dict[str, EvaluationResult]

Int64 decile column yields "1".."10") rather than factor.

dict[str, EvaluationResult]

Inside each bundle the metric is keyed by its **registered spec

dict[str, EvaluationResult]

name** (ic() → "ic"), so stacked to_frame rows from two

dict[str, EvaluationResult]

by_slice runs stay distinguishable.

dict[str, EvaluationResult]

Iteration order matches polars partition_by(as_dict=True). For

dict[str, EvaluationResult]

a cross-slice comparison table, stack the per-slice frames with

dict[str, EvaluationResult]

the standard EvaluationResult.to_frame idiom and tag each row

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

data is not a polars DataFrame.

ValueError

by not in data.columns, or data is empty.

UserInputError

metric is not a metric instance, or factor_col is absent / invalid (raised by evaluate).

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 as evaluate (date, asset_id, <factor_col>, forward_return), with the slicing column by already present.
  • metric — a metric instance (ic(), caar()), consistent with evaluate(metrics={...}). The bare class (ic) is rejected.
  • by — the partition column.
  • factor_col — the single factor to evaluate (multi-factor batching is the job of evaluate).
  • 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 sliced data panel 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:

Illustrative
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_rate is 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.

Illustrative
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:

Illustrative
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 全市場).

Illustrative
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.

Illustrative
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.

Illustrative
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:

Illustrative
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:

Illustrative
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.

Illustrative
expanded = pl.concat([
    panel.filter(expr).with_columns(pl.lit(name).alias("group"))
    for name, expr in user_definitions.items()
])
by_slice(expanded, ic(), by="group", factor_col="factor")