Skip to content

factrix.by_slice

by_slice(data: DataFrame, metric: MetricBase, *, by: str, factor_col: str, forward_periods: int | None = None, strict: bool = True) -> 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(forward_periods=5)), 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
forward_periods int | None

The data's overlap 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
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

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]

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 a raw panel 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, forward_periods=None, strict=True):

  • 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).

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

If by is not in data.columns, by_slice raises ValueError — compose the column upstream with data.with_columns(...) or data.join(...).

Cross-slice comparison table

by_slice returns a plain dict[str, EvaluationResult] (the same shape as evaluate), so 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.

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")