Skip to content

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:EvaluationResult records. The full input is one family unless expand_over further partitions it.

required
metrics list[str]

list[str] — element type strictly :class:metric label. One independent BHY screen runs per metric; the return dict is keyed by label. Single-metric callers still receive a one-key dict (no isinstance branching downstream).

required
expand_over tuple[str, ...]

Tuple of keys whose distinct value tuples split the input into independent BHY step-up buckets. Built-in field "forward_periods" is read off the result; other keys are looked up on result.params.

()
q float

Nominal FDR target. Must satisfy 0 < q < 1. Default 0.05.

0.05

Returns:

Type Description
dict[str, BhyResult]

dict[str, BhyResult] keyed by label.

Raises:

Type Description
UserInputError

metrics not a non-empty list[str]; duplicate (factor, forward_periods, *params) identifier; expand_over key missing from a result's params or naming 'factor'; metric absent from a result's outputs or its p_value missing / NaN.

Warns:

Type Description
RuntimeWarning

Input pools multiple forward_periods in the same family, reminding callers to match the family to their selection rule. Or most expand_over buckets are singletons (BHY on n=1 provides no FDR correction).


Use cases

  • Screening a candidate pool with false discovery rate (FDR) control


    Run evaluate over m candidate signals on the same return panel, feed the resulting list of EvaluationResult to bhy, and read the surviving subset off the BhyResult container. Controls FDR ≤ q under 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 per regime_id or universe_id — under the Benjamini & Bogomolov (2014) selective-inference framing. Each bucket's m, threshold, and survivors stay self-contained.

  • Auditing the family boundary


    BhyResult.expand_over / BhyResult.n_tests / BhyResult.q record the family declared and the m fed 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. Every EvaluationResult.params entry 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 of evaluate() — a different bar timeframe or universe is a different panel — so evaluate() cannot infer them; the caller stamps them on params after the fact.
  • Family partitionexpand_over alone. It is a purely statistical declaration about which hypotheses compete with each other. It may name params keys 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.

    api/evaluate →

  • partial_conjunction


    The "significant in k of m conditions" claim — use when you want a per-factor verdict across replications rather than a per-condition step-up.

    api/partial-conjunction →

  • bhy_hierarchical


    Two-stage FDR over group structure: gate at the group level, then run bhy within each rejected group.

    api/bhy-hierarchical →

  • multi_factor overview


    Module-level entry point listing all collection-level FDR functions.

    api/multi-factor →