factrix.by_slice ¶
by_slice(metric: Callable[..., MetricOutput], df: DataFrame, *, label: str, **kwargs: Any) -> SliceResult
Apply metric to each value-partition of df keyed by label.
Universe overlap (superset / multi-membership / hierarchical /
sliding window / cross-product) is composed by the caller — see
the API page for reference patterns. by_slice does no
cross-slice statistical inference; for paired comparison see
:func:factrix.slice_pairwise_test / :func:factrix.slice_joint_test.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
metric
|
Callable[..., MetricOutput]
|
Callable returning a |
required |
df
|
DataFrame
|
Metric's primary DataFrame; must already contain |
required |
label
|
str
|
Column name in |
required |
**kwargs
|
Any
|
Forwarded unchanged to |
{}
|
Returns:
| Type | Description |
|---|---|
SliceResult
|
class: |
SliceResult
|
|
SliceResult
|
meth: |
SliceResult
|
stringified (an |
SliceResult
|
iteration order matches polars |
SliceResult
|
|
SliceResult
|
inference — see API page. |
Raises:
| Type | Description |
|---|---|
TypeError
|
|
ValueError
|
|
Examples:
Per-year information coefficient (IC) on a synthetic cross-sectional panel — attach the slice label to the metric's primary DataFrame upstream, then dispatch one metric call per slice value:
>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic, compute_ic
>>> raw = fx.datasets.make_cs_panel(n_assets=100, n_dates=500)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> ic_df = compute_ic(panel).with_columns(
... pl.col("date").dt.year().alias("year")
... )
>>> per_year = fx.by_slice(ic, ic_df, label="year")
factrix.SliceResult ¶
SliceResult(data: Mapping[str, MetricOutput])
Bases: Mapping[str, MetricOutput]
Mapping of slice label → :class:MetricOutput with a frame renderer.
Returned by :func:factrix.slicing.by_slice. Iteration order matches
the upstream polars.DataFrame.partition_by order (insertion-order
of distinct values, not lexicographic) — call .sort("slice") on
the rendered frame if a stable order is needed downstream.
The container deliberately exposes only the universal projection
(name, value, stat, p_value). For metric-specific
metadata (tie_ratio, shanken_correction, ...), build the
DataFrame directly from [(k, m.metadata) for k, m in result.items()].
Warning
p_value in the rendered frame is the per-slice marginal
p-value computed by the metric on that slice alone — not
adjusted for the K parallel tests across slices. Filtering
df.filter(pl.col("p_value") < 0.05) across K=10 sectors
inflates the family-wise error rate (FWER); under H0 you expect
≈ 0.4 "significant" slices by chance. For cross-slice
inference with FWER / false discovery rate (FDR) control, use
:func:factrix.slice_pairwise_test (Holm / Romano-Wolf /
Bonferroni) or :func:factrix.slice_joint_test (omnibus χ²)
instead. The container is for exploration; the inference
functions are for claims.
Examples:
>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic, compute_ic
>>> raw = fx.datasets.make_cs_panel(n_assets=40, n_dates=240)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> ic_df = compute_ic(panel).with_columns(
... pl.col("date").dt.year().alias("year")
... )
>>> per_year = fx.by_slice(ic, ic_df, label="year")
>>> isinstance(per_year, fx.SliceResult)
True
>>> len(per_year) >= 1
True
to_frame ¶
Render slices as a long-form pl.DataFrame.
Schema (always, in order): slice_col, name, value,
stat, p_value. stat and p_value are None
when the underlying MetricOutput does not carry them
(descriptive metric, short-circuit failure path, etc.).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
slice_col
|
str
|
Output column name for the slice label. Default
|
'slice'
|
Returns:
| Type | Description |
|---|---|
DataFrame
|
|
DataFrame
|
iteration order of |
Examples:
>>> import polars as pl
>>> import factrix as fx
>>> from factrix.preprocess import compute_forward_return
>>> from factrix.metrics import ic, compute_ic
>>> raw = fx.datasets.make_cs_panel(n_assets=40, n_dates=240)
>>> panel = compute_forward_return(raw, forward_periods=5)
>>> ic_df = compute_ic(panel).with_columns(
... pl.col("date").dt.year().alias("year")
... )
>>> per_year = fx.by_slice(ic, ic_df, label="year")
>>> df = per_year.to_frame()
>>> set(df.columns) >= {"slice", "name", "value"}
True
Override the slice column name to avoid collision:
Axis-agnostic research dispatcher. Slices any metric's date-keyed
input by a column already present in the DataFrame and runs the metric
per slice. Returns a SliceResult — a
Mapping[str, MetricOutput] with a .to_frame() long-form renderer.
The axis name does not bake into the API — market, sector, regime, market-cap tier, ADV bucket all share the same dispatcher.
Argument contract¶
The first argument is the metric callable (e.g. ic, caar,
fama_macbeth); the second is the metric's primary date-keyed
DataFrame with the slicing column already present; label names
that column; remaining keyword args (forward_periods=..., etc.)
forward unchanged on every per-slice call. See the docstring
Examples block above for the canonical call shape.
SliceResult is a Mapping[str, MetricOutput]:
result["<slice-key>"].value for dict-style access,
result.to_frame() for the long-form pl.DataFrame.
Why "label is a column name"¶
by_slice does not accept a separate labels DataFrame. Users typically
already carry the partition key (market / sector / regime) on the panel
or can join it once upstream — splitting that join into the
dispatcher's signature is redundant and forces a fixed
(date, label) shape that does not generalise across axes (sector
labels are by-asset, not by-date).
If label is not in df.columns, by_slice raises ValueError —
compose the column upstream with df.with_columns(...) or
df.join(...).
What it does not do¶
by_slice performs no cross-slice statistical inference. It
returns the per-slice outputs and stops. Per-slice t-stats / SE in
each MetricOutput are computed on that slice alone (different N,
different autocorrelation structure per slice) and are not
directly comparable — max(out.values(), key=lambda m: m.tstat) is
not a defensible cross-regime 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, fama_macbeth, hit_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(label=...) 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([
df.with_columns(pl.col("market").replace(mapping).alias("uni")),
df.with_columns(pl.lit("全市場").alias("uni")),
])
by_slice(ic, expanded, label="uni")
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([
df.filter(pl.col("in_sp500")).with_columns(pl.lit("SP500").alias("uni")),
df.filter(pl.col("in_nasdaq100")).with_columns(pl.lit("N100").alias("uni")),
])
by_slice(ic, expanded, label="uni")
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([
*[
df.filter(pl.col("market_cap_rank") <= cutoff)
.with_columns(pl.lit(name).alias("tier"))
for cutoff, name in tiers
],
df.with_columns(pl.lit("All").alias("tier")),
])
by_slice(ic, expanded, label="tier")
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([
df.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(ic, expanded, label="adv_win")
5. Cross-product / multi-axis (universe × sector)¶
Not an overlap problem — label only takes a single column. Compose a
composite column with pl.concat_str:
df = df.with_columns(
pl.concat_str(["market", "sector"], separator="-").alias("uni_sec")
)
by_slice(ic, df, label="uni_sec")
# keys: "TWSE-Tech", "TWSE-Finance", "OTC-Tech", ...
The API does not accept label: list[str]: single-column vs
multi-column semantics would diverge on output dict key type
(str vs tuple), breaking the dict[str, MetricOutput] convention
downstream.
General template¶
Cases 1–4 share one idiom — per-target-slice filter +
with_columns(label=...), then pl.concat. Overlapping rows are
duplicated naturally by the concat.
expanded = pl.concat([
df.filter(expr).with_columns(pl.lit(name).alias("group"))
for name, expr in user_definitions.items()
])
by_slice(metric, expanded, label="group")
SliceResult¶
by_slice returns a SliceResult, a Mapping[str, MetricOutput]
subclass — every dict-shaped consumer (for k, v in result.items(),
result["bull"], len(result)) keeps working unchanged. The added
value is .to_frame(), which flattens per-slice MetricOutput rows
into a fixed-schema long-form pl.DataFrame for plotting,
leaderboards, and Notebook rendering.
result = by_slice(ic, ic_df, label="regime")
result.to_frame().sort("slice") # plot-ready, lexicographic order
# shape: (2, 5)
# ┌────────┬──────┬───────┬───────┬──────────┐
# │ slice │ name │ value │ stat │ p_value │
# │ --- │ --- │ --- │ --- │ --- │
# │ str │ str │ f64 │ f64 │ f64 │
# ╞════════╪══════╪═══════╪═══════╪══════════╡
# │ bear │ ic │ -0.02 │ -0.41 │ 0.683 │
# │ bull │ ic │ 0.07 │ 2.31 │ 0.024 │
# └────────┴──────┴───────┴───────┴──────────┘
# leaderboard: rank slices by t-stat magnitude
result.to_frame().sort(pl.col("stat").abs(), descending=True)
p_value is per-slice, not cross-slice-adjusted
Each row's p_value tests that slice alone against its own null
(e.g. ic mean = 0). Filtering df.filter(pl.col("p_value") < 0.05)
across K parallel slices inflates the family-wise error rate (FWER) —
under H0, K=10 sectors yields ≈ 0.4 expected "significant" slices
by pure chance. The container 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 χ²).
Schema¶
to_frame() always returns the same five columns in the same order:
| Column | Source | None when |
|---|---|---|
slice |
mapping key (rename via slice_col=) |
never |
name |
MetricOutput.name |
never |
value |
MetricOutput.value |
never |
stat |
MetricOutput.stat |
descriptive metric / short-circuit failure |
p_value |
metadata["p_value"] |
descriptive metric / short-circuit failure |
stat and p_value semantics follow the underlying metric (stat may
be a t, z, F, or χ² — see metadata["stat_type"]; p_value
may be one- or two-sided per the metric's null hypothesis). When
concatenating frames from multiple metrics
(pl.concat([by_slice(ic, ...).to_frame(), by_slice(hit_rate, ...).to_frame()])),
the stat column mixes statistic types and is not directly comparable
across rows of different name.
Row order matches iteration order of the result, which matches the
upstream polars.DataFrame.partition_by order (insertion order of
distinct values, not lexicographic). For plotting and
leaderboards, sort explicitly downstream (.sort("slice") for
deterministic axis order, .sort("value") / .sort("stat") for
ranking). The example block above shows the plot-ready idiom.
Why a fixed schema instead of cols=...?¶
Quant exploration overwhelmingly wants the same five columns — slice,
metric name, effect size, test statistic, p-value. A configurable
cols= parameter would have to choose between (a) a fixed lookup set
that excludes p-value (which lives in metadata) or (b) per-slice
metadata key discovery, whose candidate set drifts across metrics and
even across success vs. short-circuit paths within one metric. Fixed
schema avoids both failure modes; for metric-specific metadata
(tie_ratio, shanken_correction, ...), build the frame directly: