Skip to content

factrix.multi_factor.partial_conjunction

partial_conjunction(results: list[EvaluationResult], *, metrics: list[str], min_pass: int, expand_over: tuple[str, ...], n_conditions: int | None = None, q: float = 0.05) -> dict[str, PartialConjunctionResult]

Partial-conjunction screening: identities significant in min_pass of m conditions, one screen per metric.

For "factor X is significant in universes A and B" style claims, naive intersection-of-survivors does not preserve FDR (Benjamini-Bogomolov 2014). The partial conjunction test (Benjamini-Heller 2008) combines an identity's per-condition p-values into a single PC p-value, then runs BHY across identities.

PC formula (Bonferroni-style): for k = min_pass, p_PC = (m - k + 1) * p_((k)) capped at 1. k = 1 reduces to m * min(p) (union semantics) and is forbidden.

Parameters:

Name Type Description Default
results list[EvaluationResult]

:class:EvaluationResult records. The aggregation identity is the hypothesis identifier minus the expand_over components; results sharing an identity form that identity's conditions and must differ on at least one expand_over key. Any other swept knob (params key or forward_periods outside expand_over) keeps identities apart instead of inflating m.

required
metrics list[str]

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

required
min_pass int

k in "k of m". Must be >= 2.

required
expand_over tuple[str, ...]

Non-empty tuple of params keys (or "forward_periods") defining the condition axis.

required
n_conditions int | None

Strict condition-count declaration. None lets m be inferred per identity; an int requires every identity to have exactly that many conditions.

None
q float

Nominal FDR target for the BHY step-up over PC p-values. Must satisfy 0 < q < 1.

0.05

Returns:

Type Description
dict[str, PartialConjunctionResult]

dict[str, PartialConjunctionResult] keyed by

dict[str, PartialConjunctionResult]

label.

Raises:

Type Description
UserInputError

min_pass < 2; expand_over empty; n_conditions < min_pass; condition-count mismatch; identity with fewer than min_pass conditions; any _resolve_family invariant failure.

Contract-bearing screening for the "factor X is significant in \(k\) of \(m\) conditions" claim. Replaces the notebook idiom set(survivors_a) & set(survivors_b), which does not preserve false discovery rate (FDR) (Benjamini & Bogomolov 2014), with the partial conjunction test of Benjamini & Heller (2008).

import dataclasses

import factrix as fx
from factrix.metrics import ic

# "Momentum is significant in BOTH large-cap AND small-cap universes"
# evaluate() returns dict[str, EvaluationResult]; pull the single result
# and stamp the universe label onto it with dataclasses.replace so
# expand_over can split the family on params["universe_id"].
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_large, "mom", universe_id="large_cap"),
    stamp(panel_small, "mom", universe_id="small_cap"),
    # ... + value, quality, etc. one result per (factor, universe) cell
]
survivors = fx.multi_factor.partial_conjunction(
    results,
    metrics=["ic"],
    min_pass=2,
    n_conditions=2,
    expand_over=("universe_id",),
    q=0.05,
)

Versus bhy(expand_over=...) — same data, different question

Both functions accept expand_over=, but the survivor unit and the question answered differ. This is the single most common source of confusion; pick the row that matches your claim.

Function Survivor unit Question Example claim
bhy(results, expand_over=("universe_id",)) (factor, universe) pair "Where is this factor significant?" "Momentum is significant in large_cap; value is significant in small_cap"
partial_conjunction(results, min_pass=2, expand_over=("universe_id",)) factor identity "Which factors are significant across all conditions?" "Momentum is significant across both universes"

In other words: bhy treats each universe as its own hypothesis and expands the family; partial_conjunction treats each universe as a condition the factor must pass jointly and aggregates back to one hypothesis per factor.

When not to reach for partial_conjunction

Real intent Reach for Why
"At least any condition is significant" bhy(results, expand_over=(...)) min_pass=1 is union semantics — FDR inflates to ~2q. partial_conjunction raises rather than implement this.
Rank candidates (no FDR control) compare compare is a view, not a filter.
Sensitivity to estimator / sample choice a dedicated robustness sweep Conditions there are methods, not data slices.
Cross-slice metric difference (descriptive) by_slice Returns per-slice metric values; no inference.
Cross-slice metric difference (inferential, slice-pairs) slice_pairwise_test / slice_joint_test Tests whether the slices' metric series differ, not whether the factor is jointly significant.

Strict vs lenient mode

n_conditions is the contract knob.

DataStructure When Behavior
Strict (n_conditions=int) Paper-grade; you know the design (e.g. exactly 2 universes, exactly 4 horizons) Identity with any condition count other than n_conditions raises. Data gaps surface fail-loud.
Lenient (n_conditions=None) EDA / prototyping; condition count varies by identity m inferred per identity from the data; only requires m >= min_pass.
# Strict: 2 universes required for every factor; missing one raises.
fx.multi_factor.partial_conjunction(
    results, min_pass=2, n_conditions=2, expand_over=("universe_id",)
)

# Lenient: "at least 3 of however many horizons each factor has".
fx.multi_factor.partial_conjunction(
    results, min_pass=3, expand_over=("fwd_period",)
)

How the math works

Per identity, the \(m\) per-condition \(p\)-values are reduced to a single PC \(p\)-value (Bonferroni-style, BH2008):

\[ p_{\text{PC}}^{(k/m)} = \min\bigl(1,\; (m - k + 1) \cdot p_{(k)}\bigr) \]

where \(p_{(k)}\) is the \(k\)-th smallest of the \(m\) \(p\)-values and \(k = \texttt{min\_pass}\). Two corner cases worth knowing:

  • \(k = m\) (full conjunction) → \(p_{\text{PC}} = \max(p)\). Reject only when even the worst condition is significant.
  • \(k = 1\) (union) → \(p_{\text{PC}} = m \cdot \min(p)\). Bonferroni-corrected minimum. Forbidden here — the surface raises with a pointer to bhy(expand_over=...), where the family-level FDR inflation is explicit rather than hidden in a "robust across" claim.

The PC \(p\)-values are then fed to a standard Benjamini-Hochberg-Yekutieli (BHY) step-up across identities, controlling group-level FDR ≤ q. The harmonic dependence correction \(c(m) = \sum 1/i\) is applied because PC \(p\)-values across identities are not generally positive regression dependence on a subset (PRDS) — sharing underlying panels makes the joint distribution unknown, so the conservative choice is the default.

BH2008 also presents a Simes-style PC combiner, which is less conservative under PRDS. factrix ships only the Bonferroni-style — it is uniformly valid without dependence assumptions; a Simes path may be added later if a use case demands it.

Result output

partial_conjunction returns a PartialConjunctionResult per metric — the same _FdrResultBase shape as bhy's BhyResult (entries / survivors / adj_p / q / n_tests), plus PC-specific fields:

Field Meaning
entries One representative result per tested identity (first result of that identity, input order) — every identity, not just survivors
adj_p_all BHY-adjusted PC \(p\)-value, aligned with entries; identity survives iff adj_p_all <= q
pc_p_all Raw PC \(p\)-value (pre-BHY), aligned with entries
survivors / adj_p Surviving subset and its adjusted p-value (derived from adj_p_all <= q)
min_pass The \(k\) you passed
n_tests Keyed by the identity tuple — factor, then forward_periods and params items not named by expand_over → condition count \(m\) for that identity
n_passed_uncorr_all Per-identity count of raw \(p < q\), aligned with entries. Descriptive — flags borderline (n_passed_uncorr_all == min_pass) and data-gap cases at a glance. Cutoff is your q, so the count moves with q — using it to override adj_p survivor selection is the anti-shopping failure mode this function exists to prevent.

to_frame() gives a factor | adj_p | survived DataFrame over every tested identity, eliminated ones included.

factrix.multi_factor.PartialConjunctionResult dataclass

PartialConjunctionResult(metric_name: str, entries: list[EvaluationResult], adj_p_all: ndarray, q: float, n_tests: Mapping[tuple[Any, ...], int], pc_p_all: ndarray, expand_over: tuple[str, ...], min_pass: int, n_passed_uncorr_all: ndarray)

Bases: _FdrResultBase

Per-identity partial-conjunction survivors for one metric.

Shares metric_name / entries / adj_p_all / q / n_tests with :class:_FdrResultBase. entries is one representative :class:EvaluationResult per identity; adj_p_all is the BHY-adjusted PC p-value; n_tests is the condition count per identity, keyed by the identifier with the expand_over components stripped (factor, then forward_periods and params items not named by expand_over).

Attributes:

Name Type Description
pc_p_all ndarray

Raw PC p-value aligned with entries.

expand_over tuple[str, ...]

params keys defining the condition axis.

min_pass int

k in the k of m partial conjunction test.

n_passed_uncorr_all ndarray

Per-identity count of raw p-values strictly below q (descriptive — not used in inference), aligned with entries.

Validation summary

Trigger Outcome
min_pass < 2 UserInputError. min_pass == 1 additionally points at bhy(expand_over=...).
expand_over empty / None UserInputError — the function is undefined without a condition axis.
expand_over names an identity field (factor / forward_periods) UserInputError (anti-shopping defense — same as bhy).
n_conditions < min_pass UserInputError (unsatisfiable).
Strict mode: identity's condition count \(\neq\) n_conditions UserInputError — surfaces missing-universe / missing-horizon data gaps.
Identity with condition count \(<\) min_pass (lenient) UserInputError.
Duplicate (identity, expand_over_values) partition key UserInputError (family-resolution invariant).

References

  • [BH2008] Benjamini, Y. & Heller, R. (2008). Screening for partial conjunction hypotheses. Biometrics, 64(4), 1215–1222.
  • [BB2014] Benjamini, Y. & Bogomolov, M. (2014). Selective inference on multiple families of hypotheses. JRSS-B, 76(1).
  • [HY2014] Heller, R. & Yekutieli, D. (2014). Replicability analysis for genome-wide association studies. AOAS, 8(1).