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: |
required |
metrics
|
list[str]
|
|
required |
group
|
str
|
Single key naming the group axis. |
required |
q
|
float
|
Nominal FDR target shared by both layers. Must satisfy
|
0.05
|
Returns:
| Type | Description |
|---|---|
dict[str, HierarchicalBhyResult]
|
|
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
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:
-
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.
-
Outer BHY across the \(G\) group representatives gives \(p_{\text{outer},g}^{\text{adj}}\).
-
Inner BHY within each group gives \(p_{\text{inner},i}^{\text{adj}}\) for member \(i\) of group \(g(i)\).
-
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
|
|
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_hierarchicalassumes 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.