Estimator alternatives
How to swap the heteroskedasticity-and-autocorrelation-consistent (HAC) inference path that drives primary_p, and why
factrix's design keeps that swap study-scoped rather than per-call.
The choice¶
For HAC-on-mean cells (information coefficient (IC) PANEL, FM PANEL, CAAR PANEL) you can choose
which HAC standard-error path computes primary_p:
| Estimator | When to pick |
|---|---|
NeweyWest() (default) |
Bartlett kernel + NW1994 auto-bandwidth + Hansen-Hodrick overlap floor. Universally applicable; produces a positive-semidefinite variance estimate by construction. |
HansenHodrick() |
Rectangular kernel matched to the MA(h-1) overlap structure forward returns induce. Closed-form variance under the textbook overlap assumption; no PSD guarantee on short / mildly anti-correlated samples (factrix clamps to 0 and emits WarningCode.RECT_KERNEL_NEGATIVE_VARIANCE). Applicable to (INDIVIDUAL, CONTINUOUS) cells only. |
import factrix as fx
from factrix.stats import HansenHodrick
cfg = fx.AnalysisConfig.individual_continuous(
metric=fx.Metric.IC,
forward_periods=5,
estimator=HansenHodrick(), # default NeweyWest()
)
profile = fx.evaluate(panel, cfg)
profile.primary_stat_name # StatCode.T_HH
profile.primary_p # HH p-value, drives bhy / bonferroni / etc.
profile.context["estimator"] # "HansenHodrick" — audit trail
The estimator is part of the cfg, so it serializes with to_dict()
and rehydrates via AnalysisConfig.from_dict(...):
cfg.to_dict()
# {"scope": "individual", "signal": "continuous", "metric": "ic",
# "forward_periods": 5, "estimator": "HansenHodrick"}
# Round-trip is exact; missing-key legacy dicts fall back to NeweyWest()
restored = fx.AnalysisConfig.from_dict(cfg.to_dict())
restored == cfg # True
Why study-scoped, not per-call¶
Harvey, Liu, Zhu (2016, "... and the Cross-Section of Expected Returns," RFS) is the canonical citation for cross-sectional specification-search bias: when researchers can swap inference methods freely after seeing results, p-values lose their guarantee. The straightforward defence is "always use one estimator forever," but that's stronger than what HLZ actually argue — they argue against post-hoc estimator picking, not against estimator plurality itself.
factrix's design splits the difference:
AnalysisConfig.estimatoris set once per study. The factory call sites are the only place to wire it; there is noevaluate(panel, cfg, estimator=...)per-call kwarg by design.- Provenance lands in
profile.context["estimator"]. Audit-time review can see which estimator drove each profile; running the same factor under both Newey-West (NW) and Hansen-Hodrick (HH) produces two profiles whosecontextmakes the difference visible (and downstream tools that detect "same identity, different context" can flag the A/B path). - Benjamini-Hochberg-Yekutieli (BHY) family-function false discovery rate (FDR) doesn't fan out on estimator. Same
hypothesis under a different estimator is not a new hypothesis —
bhy([nw_profile, hh_profile])with the samefactor_idraises on duplicate identity rather than silently widening the family.
The defence is "the choice is recorded and visible" rather than "the
choice is locked." Pre-registered single-factor studies can ship one
estimator; methodology papers comparing estimators ship multiple cfgs
under different factor_ids.
What changes when you swap¶
The shape of profile.stats changes — only the chosen estimator's
(stat_name, p_name) pair populates the inference layer:
# default cfg — NW path
profile = fx.evaluate(panel, fx.AnalysisConfig.individual_continuous(metric=fx.Metric.IC))
sorted(profile.stats) # [MEAN, P_NW, T_NW]
# HH cfg
profile = fx.evaluate(
panel,
fx.AnalysisConfig.individual_continuous(metric=fx.Metric.IC, estimator=HansenHodrick()),
)
sorted(profile.stats) # [MEAN, P_HH, T_HH]
This is a v0.13 BREAKING change from v0.12, which auto-side-emitted
both pairs on every IC / FM PANEL evaluate. Downstream code should
read profile.primary_p / profile.primary_stat_name (canonical,
estimator-agnostic) rather than hardcoded stats[StatCode.T_NW] /
stats[StatCode.P_HH] lookups; see the CHANGELOG entry for the full
migration note.
Inapplicable estimators raise early¶
The applicability gate runs at AnalysisConfig construction, not at
evaluate. Mis-matched cells fail loud:
fx.AnalysisConfig.common_continuous(estimator=HansenHodrick())
# IncompatibleAxisError: estimator='HansenHodrick' not applicable to
# (scope=common, signal=continuous). Applicable HAC estimators: NeweyWest
To inspect what's available for a given cell:
fx.list_estimators(fx.FactorScope.INDIVIDUAL, fx.Signal.CONTINUOUS)
# ['BlockBootstrap', 'HansenHodrick', 'NeweyWest', 'WaldNWCluster', 'WaldTwoWayCluster']
list_estimators returns every registered Estimator (selection-axis
and HAC-axis); the construction gate further narrows to HACEstimator
instances applicable to the cell.
When not in scope¶
- Per-factor estimator swap. Permanently not opened — single-cfg decision frequency is the spec-search lock.
- Slope-axis HAC (TS β, TS Dummy). Single-asset ordinary least squares (OLS) regressions
with NW HAC SE on the slope run a different math shape (
(y, x) → β, SE(β)) than the series-meancompute(series, *, forward_periods)contract. A slope-axis sub-protocol is tracked for a future release. - Multi-horizon generalized method of moments (GMM) cell auto-dispatch. The
GMMMomentEstimatoritself ships now (see below), but factrix does not yet auto-build the multi-horizon moment matrix from a raw forward-return panel. Users construct moments themselves and callGMM().compute(...)directly. The integrated cell — including horizon-grid specification oncfg, per-horizon IC construction, and side-emit / alternative-path dispatch semantics — is tracked as a follow-up to #191.
GMM moment-condition tests¶
The GMM MomentEstimator ships as a standalone primitive for
Hansen (1982) over-identifying-restriction tests. Pure
over-identification (n_params = 0) is the only mode in this
release — the null is E[g] = 0 for all K moments, with the
J-statistic distributed χ²(K) under H₀.
import numpy as np
from factrix.stats import GMM
# Build a (T, K) moment matrix yourself — e.g. per-date IC at K
# forward horizons, factor-sorted decile spreads at K horizons,
# cross-asset shared-β residuals at K asset groups, etc.
moments = build_my_moment_system(panel, ...) # shape (T, K)
result = GMM().compute(moments, forward_periods=max_horizon)
result.j_stat # Hansen J statistic (chi-square under H₀)
result.df # K - n_params (n_params = 0 in this release)
result.overid_p # right-tail χ²_df p-value
result.warnings # SINGULAR_WEIGHT_MATRIX if Ŝ rank-deficient
forward_periods floors the long-run-covariance bandwidth at
forward_periods - 1, sharing the convention with NeweyWest /
HansenHodrick. Solver tuning (max_iter) lives on the instance:
Why no auto-dispatched cell yet¶
The choice of moment-condition system (multi-horizon panel?
multi-bucket spread? cross-sectional shared-β?) is a research-design
question with no canonical factrix default. The standalone primitive
ships now so users with a specific moment system can run J-tests
immediately; the integrated multi-horizon cell lands as a focused
follow-up so its design (horizon-grid spec, alternative-path vs
side-emit, EMITS_STATS extension, K-scaled min_periods) gets a
clean review pass.
Wiring in AnalysisConfig¶
cfg.moment_estimator: MomentEstimator | None exists for the
integrated path; setting it without a corresponding cell procedure
is a no-op at evaluate-time but round-trips through
to_dict / from_dict:
cfg = fx.AnalysisConfig.individual_continuous(moment_estimator=GMM())
cfg.to_dict()["moment_estimator"] # "GMM"
Pre-#191 serialized configs without the moment_estimator key are
read back with moment_estimator=None, preserving backward
compatibility.