Skip to content

factrix.multi_factor.bhy_hierarchical

bhy_hierarchical(results: list[EvaluationResult], *, metrics: list[str], group: str, q: float = 0.05) -> dict[str, HierarchicalBhyResult]

Yekutieli (2008) two-stage hierarchical BHY, one screen per metric.

Controls group-level FDR ≤ q on the outer layer (Simes group representative + BHY) and within-group FDR ≤ q on the inner layer (BHY restricted to passing groups). Flat BHY across the whole input loses group-level interpretability and pays full m-correction even when most groups are dead.

Parameters:

Name Type Description Default
results list[EvaluationResult]

:class:EvaluationResult records. Each is assigned to one group via result.params[group] (or via result.forward_periods if group == "forward_periods"). Within a group, each member is one hypothesis of the inner family; the (factor, forward_periods, *params) identifier must be unique across the input.

required
metrics list[str]

list[str] — one hierarchical screen per metric; return dict keyed by label.

required
group str

Single key naming the group axis.

required
q float

Nominal FDR target shared by both layers. Must satisfy 0 < q < 1.

0.05

Returns:

Type Description
dict[str, HierarchicalBhyResult]

dict[str, HierarchicalBhyResult] keyed by label.

Raises:

Type Description
UserInputError

group == 'factor' (it is the identifier); only one distinct group value in the input (call bhy instead); every result is its own group at n >= 3; duplicate (factor, forward_periods, *params) identifier; any _resolve_family invariant failure.

Warns:

Type Description
RuntimeWarning

More than half of input groups contain a single result — inner BHY on n=1 is a raw cutoff and the outer Simes representative equals that single p-value.

Two-stage false discovery rate (FDR) for factor sets with natural group structure (factor families, regions, sectors). Outer Benjamini-Hochberg-Yekutieli (BHY) on Simes (1986) group representatives + inner BHY within each passing group, per Yekutieli (2008).

import dataclasses

import factrix as fx
from factrix.metrics import ic

# "Which factor families have signal, and within those, which factors?"
# evaluate() returns dict[str, EvaluationResult]; pull the single result
# and stamp the family label onto it with dataclasses.replace so the
# outer screen can group on params["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_mom_1m, "mom_1m", family="momentum"),
    stamp(panel_mom_12m, "mom_12m", family="momentum"),
    stamp(panel_pb, "pb", family="value"),
    stamp(panel_pe, "pe", family="value"),
    # ... + quality, low-vol, etc.
]
survivors = fx.multi_factor.bhy_hierarchical(
    results, metrics=["ic"], group="family", q=0.05
)

Which function fits this question?

Same input shape (one result per (factor, condition)), three different claims:

Claim Survivor unit Function
"Factor X significant in each condition / universe" (factor, condition) pair bhy(expand_over=)
"Factor X significant in \(\ge k\) of \(m\) conditions" factor identity partial_conjunction
"Which families have signal, and within those, which factors?" factor identity (group-then-within) bhy_hierarchical

bhy_hierarchical is the only one of the three that keeps the family-level answer first-class — readers learn both "5 of 8 families showed signal" and "within those, factors A / B / C survived" from a single HierarchicalBhyResult.

How the math works

Per group \(g\) with \(m_g\) member p-values:

  1. Compute the group representative

    \[ p_{\text{Simes},g} = \min_{k=1,\ldots,m_g} \frac{m_g}{k} \cdot p_{(k),g} \]

where \(p_{(k),g}\) is the \(k\)-th smallest p-value in group \(g\). Simes dominates the Bonferroni representative \(m_g \cdot \min(p)\) and is the Yekutieli 2008 recommended choice.

  1. Outer BHY across the \(G\) group representatives gives \(p_{\text{outer},g}^{\text{adj}}\).

  2. Inner BHY within each group gives \(p_{\text{inner},i}^{\text{adj}}\) for member \(i\) of group \(g(i)\).

  3. The cell-level adjusted p is the max-of-layers fold

    \[ p_i^{\text{adj}} = \max\bigl(p_{\text{outer},g(i)}^{\text{adj}},\; p_{\text{inner},i}^{\text{adj}}\bigr) \]

This preserves the universal survivor[i] iff adj_p[i] <= q duality while encoding the two-layer logic: a cell can fail because its group failed outer, because the cell itself failed inner, or both.

Result output

bhy_hierarchical returns a HierarchicalBhyResult per metric — the _FdrResultBase shape (entries / survivors / adj_p / q / n_tests) plus a hierarchy-specific field:

Field Meaning
survivors Surviving EvaluationResult records, input order (derived: adj_p_all <= q)
adj_p Max-of-layers \(\text{adj}_p\) for the survivors; survivor iff adj_p <= q
q The q you passed (single target, both layers)
group Context key naming the group axis (a single str)
n_tests Mapping (group_value,) -> m_group for every input group (covers dead families too, so "N of M families survived" claims are computable directly). Counter to partial_conjunction, which keeps surviving identities only.

Per-survivor group label: survivor.params[result.group].

factrix.multi_factor.HierarchicalBhyResult dataclass

HierarchicalBhyResult(metric_name: str, entries: list[EvaluationResult], adj_p_all: ndarray, q: float, n_tests: Mapping[tuple[Any, ...], int], group: str)

Bases: _FdrResultBase

Two-stage hierarchical BHY survivors for one metric.

Shares metric_name / entries / adj_p_all / q / n_tests with :class:_FdrResultBase. adj_p_all is max(outer_adj_p[group], inner_adj_p[i]) aligned with entries so entry[i] survives iff adj_p_all[i] <= q; q is shared by both layers; n_tests is the per-group inner family size keyed by (group_value,) — covering all input groups, not just survivors.

Attributes:

Name Type Description
group str

params key naming the group axis.

When not to reach for bhy_hierarchical

Real intent Reach for Why
No natural group structure bhy The grouping is real or it isn't; faking a group axis trivializes the procedure.
"Factor X passes in every condition" partial_conjunction with min_pass == m Hierarchical is "group-then-within", not "joint across conditions".
Flat BHY split by family for display only bhy(expand_over=["family"]) Independent step-ups per bucket, no group-level inference. Use when you do not need a "this family has signal" answer.
Mixed-sign factors in one bucket Split the bucket / pre-orthogonalize Within-group Simes assumes positive regression dependence on a subset (PRDS); structurally opposite factors (e.g. momentum + reversal in one group) can violate it.

Validation summary

Trigger Outcome
group shadows an identity field (factor / forward_periods) UserInputError.
group key missing from a result's params UserInputError.
Only one distinct group value across input UserInputError — points at bhy.
Every result is its own group at \(n \ge 3\) (group axis near-unique) UserInputError — pick a coarser categorical.
Duplicate (identity, group_value) partition key UserInputError.
More than half of input groups contain a single result RuntimeWarning — inner BHY on \(n=1\) is a raw cutoff.

Caveats

  • Simes outer representative: not exposed as a kwarg. Dominates Bonferroni-min under PRDS; Edgington-style mean-p has no valid null distribution and is rejected.
  • PRDS within group: Simes is valid under positive regression dependence — typical for factors within one family that share style exposure. If a group mixes structurally opposite factors (e.g. momentum + reversal in one bucket), the within-group PRDS assumption can fail; split the group or pre-orthogonalize.
  • Pre-filtered input: bhy_hierarchical assumes the input is the candidate family. If results came from upstream pre-filtering (e.g. top-50 of 500 candidates), the FDR claim does not cover the full screening pipeline — track \(K\) per the experiment-log discipline.
  • Composed FDR is approximate at exact \(q\): Yekutieli 2008 bounds group-level FDR \(\le q\) and within-group FDR \(\le q\) conditional on group passing; the composed per-hypothesis FDR under PRDS is bounded but not exactly \(q\). Researcher claims should be "FDR-controlled at \(q\) in each layer", not "joint FDR \(= q\)".

References

  • [S1986] Simes, R. J. (1986). An improved Bonferroni procedure for multiple tests of significance. Biometrika, 73(3), 751–754.
  • [Y2008] Yekutieli, D. (2008). Hierarchical false discovery rate-controlling methodology. JASA, 103(481), 309–316.
  • [NBER34050] NBER WP 34050 (2025). Hierarchical Multiple Testing in Empirical Asset Pricing.