factrix.multi_factor.bhy ¶
bhy(results: list[EvaluationResult], *, metrics: list[str], expand_over: tuple[str, ...] = (), q: float = 0.05) -> dict[str, BhyResult]
Benjamini-Hochberg-Yekutieli step-up FDR, one screen per metric.
The input list is treated as a single family. When expand_over
is non-empty, one independent step-up runs per unique tuple of
expand_over values (read from EvaluationResult.forward_periods
for that built-in partition key, otherwise from
EvaluationResult.params[k]). Pooling horizons in one family is
appropriate when selection may choose across horizons; partitioning by
horizon is appropriate only for predeclared, separately reported screens.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
results
|
list[EvaluationResult]
|
:class: |
required |
metrics
|
list[str]
|
|
required |
expand_over
|
tuple[str, ...]
|
Tuple of keys whose distinct value tuples split
the input into independent BHY step-up buckets. Built-in
field |
()
|
q
|
float
|
Nominal FDR target. Must satisfy |
0.05
|
Returns:
| Type | Description |
|---|---|
dict[str, BhyResult]
|
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Warns:
| Type | Description |
|---|---|
RuntimeWarning
|
Input pools multiple |
Use cases¶
-
Screening a candidate pool with false discovery rate (FDR) control
Run
evaluateovermcandidate signals on the same return panel, feed the resulting list ofEvaluationResulttobhy, and read the surviving subset off theBhyResultcontainer. Controls FDR ≤qunder arbitrary dependence — the regime that matches a correlated factor pool (e.g. 200 momentum variants on one panel). -
Splitting into independent families
Pass
expand_over=(<params key>,)to run one step-up per distinct bucket — for instance perregime_idoruniverse_id— under the Benjamini & Bogomolov (2014) selective-inference framing. Each bucket'sm, threshold, and survivors stay self-contained. -
Auditing the family boundary
BhyResult.expand_over/BhyResult.n_tests/BhyResult.qrecord the family declared and themfed into each bucket's step-up, so the FDR claim is self-contained in the return object.
BhyResult attributes¶
The returned dictionary maps each mainstream metric label to a BhyResult container, which exposes:
| Attribute | Type | Meaning |
|---|---|---|
metric_name |
str |
Name of the metric driving the screen. |
entries |
list[EvaluationResult] |
Every tested factor, input order — survivors and eliminated alike. |
adj_p_all |
np.ndarray |
Bucket-local Benjamini-Hochberg-Yekutieli (BHY)-adjusted p-value, index-aligned with entries; NaN for an entry dropped before the family formed. |
survivors |
list[EvaluationResult] |
Surviving subset (derived: adj_p_all <= q). |
adj_p |
np.ndarray |
Adjusted p-value for the survivors, aligned with survivors (derived). |
q |
float |
The nominal target FDR you passed. |
expand_over |
tuple[str, ...] |
() for a single family; ("regime_id",) etc. otherwise. |
n_tests |
Mapping[tuple, int] |
{(): N} or {bucket_key: m_per_bucket}. |
Call result.to_frame() for a factor | adj_p | survived DataFrame over
all tested factors — so a screen of N factors passing 2 still shows how
far the eliminated N-2 sat from the threshold, rather than discarding them.
Jupyter rendering surfaces a three-column text / HTML table of
factor | adj_p for the survivors, plus an expand_over_values column
when buckets are declared.
factrix.multi_factor.BhyResult
dataclass
¶
BhyResult(metric_name: str, entries: list[EvaluationResult], adj_p_all: ndarray, q: float, n_tests: Mapping[tuple[Any, ...], int], expand_over: tuple[str, ...])
Bases: _FdrResultBase
Result of one BHY step-up for one metric.
Shares metric_name / entries / adj_p_all / q /
n_tests with :class:_FdrResultBase. adj_p_all is bucket-local;
n_tests is keyed by expand_over_values tuple (() for
single-bucket).
Attributes:
| Name | Type | Description |
|---|---|---|
expand_over |
tuple[str, ...]
|
Keys used to partition the input into independent step-up buckets; empty tuple for the single-bucket case. |
Parameters¶
| Kwarg | Default | Meaning |
|---|---|---|
metrics |
(required) | list[str] of metric labels to run the FDR screen for. |
expand_over |
() |
Params keys whose distinct value tuples split the input into independent step-ups. Names must live in EvaluationResult.params (except for the built-in "forward_periods"). Naming a metadata key is rejected — bookkeeping does not define a family. |
q |
0.05 |
Nominal false discovery rate target. The Benjamini–Yekutieli \(c(m)\) correction is applied internally — pass the level you actually want; do not pre-divide. |
Identity vs partition (anti-shopping defense)¶
Two separate concerns, on two separate knobs:
- Identity —
(factor, forward_periods, *params). It names which hypothesis this is. EveryEvaluationResult.paramsentry joins it automatically, so a swept knob (timeframe,universe_id, …) never has to be encoded into the factor name to stay unique. These knobs change the input panel upstream ofevaluate()— a different bar timeframe or universe is a different panel — soevaluate()cannot infer them; the caller stamps them onparamsafter the fact. - Family partition —
expand_overalone. It is a purely statistical declaration about which hypotheses compete with each other. It may nameparamskeys or the built-in"forward_periods", never the factor name.
EvaluationResult.metadata is bookkeeping (run_id, data vintage). It joins
neither. Two results differing only in metadata are the same hypothesis and
raise as a duplicate — that check exists to catch a hypothesis submitted twice,
and a bookkeeping label must not defeat it.
Concretely: if expand_over=["factor"] were allowed, every factor would land in
its own size-1 family and pass its own step-up trivially — FDR control across the
screening universe collapses.
With expand_over=(), all hypotheses enter one step-up. Pooling makes the family
larger and the per-rank threshold stricter, so it is the correct — and
conservative — choice whenever the research process may select the winner across
the swept knobs. Use expand_over=(…) only when the buckets were predeclared and
will be selected and reported separately; separate buckets provide no global
control over later shopping across them.
The three roles a knob can play¶
| User intent | Where the knob lives | API call | Family scope per step-up |
|---|---|---|---|
"This study runs on tw50 only" — a pre-registered scope, not a tested dimension |
filter upstream; the knob need not be stamped at all | bhy([r for r in results if …], metrics=["ic"]) |
factor × forward_periods |
"Sweep timeframe ∈ {1h, 4h, 1d} and take the best" — a tested dimension, winner selected across it |
params |
bhy(results, metrics=["ic"]) |
factor × forward_periods × timeframe, one step-up over all of them |
"Report predeclared tw50 and tw100 screens separately, with no cross-universe winner selection" |
params + expand_over |
bhy(results, metrics=["ic"], expand_over=("universe_id",)) |
one step-up per universe |
| "Record which pipeline run produced this" — never affects inference | metadata |
— | not applicable |
The middle row is the one that used to require encoding the knob into the factor
name. It no longer does: stamping timeframe on params makes each hypothesis
uniquely identified while leaving them all in a single family.
evaluate() itself takes no params= kwarg — a swept knob changes the input
panel upstream of evaluate() (a different timeframe is a different panel),
so evaluate() has no way to know it. The caller stamps params on the
returned EvaluationResult with dataclasses.replace, same pattern as
bhy_hierarchical and
partial_conjunction:
import dataclasses
import factrix as fx
from factrix.metrics import ic
# "Sweep timeframe and let the winner be picked across all of them" —
# evaluate() returns dict[str, EvaluationResult]; pull the single result
# and stamp the timeframe label onto it with dataclasses.replace so the
# identifier stays unique without splitting the family.
def stamp(panel, factor_col, **params):
res = fx.evaluate(panel, metrics={"ic": ic()}, factor_cols=[factor_col])[factor_col]
return dataclasses.replace(res, params=params)
results = [
stamp(panel_1h, "mom", timeframe="1h"),
stamp(panel_4h, "mom", timeframe="4h"),
stamp(panel_1d, "mom", timeframe="1d"),
]
survivors = fx.multi_factor.bhy(results, metrics=["ic"], q=0.05) # one step-up over all three
See also¶
-
Batch evaluation
Evaluate multiple candidate factors and multiple metrics in a single execution DAG.
-
partial_conjunction
The "significant in
kofmconditions" claim — use when you want a per-factor verdict across replications rather than a per-condition step-up. -
bhy_hierarchical
Two-stage FDR over group structure: gate at the group level, then run
bhywithin each rejected group. -
multi_factoroverview
Module-level entry point listing all collection-level FDR functions.