factrix.inspect_data ¶
inspect_data(data: Any, factor_cols: Sequence[str] | None = None, *, expected_warnings: tuple[str, ...] = ()) -> DataInspection
Inspect data and return typed dispatch-axis + per-metric verdict.
Pre-flight introspection: typed detection plus, for every
role=SpecRole.METRIC :class:MetricSpec in the registry, a
:class:MetricApplicability verdict combining (a) cell-match
against the detected (scope, density, structure) and (b) the
spec's optional :class:SampleThreshold against the data's
n_periods / n_assets / n_pairs / n_events.
The n_events floor is pre-flighted against the non-zero factor
count for event-driven metrics; a metric with a dynamic event floor
(caar) is pre-flighted at its default config, and its in-body
short-circuit on the actual run-time params stays authoritative.
Missing cells never move an axis. Scope is read from finite factor
cells only — a broadcast common factor with a gap on one asset stays
COMMON, and a period left with a single finite cell is still
compatible with COMMON. A period with no finite cell carries no
evidence and is ignored; if every period is missing, the axis is
unidentifiable, routing falls back to the unrestricted
FactorScope.INDIVIDUAL, and a FACTOR_SCOPE_UNIDENTIFIABLE
data-level warning names the count the fallback was taken on.
evaluate dispatches through the same detector, so pre-flight scope
and the cell a run is routed to cannot disagree.
Content-based gates beyond cell and sample shape follow the run-time
contract. A metric declaring requires_continuous_magnitude (e.g.
event_ic) is blocked on a discrete ±k signal (|factor| constant
across events, such as a ternary {-1, 0, +1} indicator), matching its
run-time not_applicable_discrete_signal short-circuit. For a COMMON
factor, common_quantile_spread is blocked when its usable per-date
history has fewer than 2 * n_groups distinct values. A one-sided
factor leaves directional_hit_rate's point estimate defined but its
test degenerate, so that state is reported as a degraded
DEGENERATE_VARIANCE advisory rather than as an unusable metric.
Multi-factor input is inspected per column, the granularity
:func:factrix.evaluate dispatches at: :attr:DataInspection.factors
maps each inspected column to a :class:FactorInspection carrying that
column's scope, density, counts, stage-one profiles, warnings and full
metric verdicts — nothing there is inherited from a sibling column.
Two granularities rather than one, deliberately: a screen over dozens of
candidate columns still wants a single answer to "what is this panel", so
:attr:~DataInspection.properties, :attr:~DataInspection.metrics and the
usable / degraded / unusable partitions remain the concise
aggregate and describe the first inspected column (the whole panel
when there is only one). They are exactly factors[<first column>], so
single-factor callers never touch the mapping and multi-factor callers
never have to guess which column an aggregate refers to. When columns
disagree on FactorDensity or FactorScope, a
CROSS_FACTOR_*_MISMATCH data-level warning names the basis column, the
columns that disagree with it and the value each carries.
warnings on the result is the first column's warnings plus those
cross-factor mismatches; a later column's own advisories live on
factors[col].warnings rather than being merged into one stream where
the column they belong to would be lost.
One panel-level caveat: n_periods and n_assets are properties of
the data, so every :class:FactorInspection reports the panel's counts,
not the periods that column happens to cover. Where a column's own
coverage gates a metric it does so through the stage-one profile, which
is computed per column (a column whose IC cross-sections survive on 20
periods is blocked at ic's 50-period floor while its sibling is not).
Common-beta consumers likewise apply their asset floor to the per-asset
regressions surviving compute_common_betas, not the raw universe.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Any
|
Long-format factor data with the canonical columns.
The baseline columns |
required |
factor_cols
|
Sequence[str] | None
|
Optional list of factor columns to check. When
|
None
|
expected_warnings
|
tuple[str, ...]
|
:class: |
()
|
Returns:
| Type | Description |
|---|---|
DataInspection
|
class: |
Examples:
>>> import factrix as fx
>>> import polars as pl
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> info = fx.inspect_data(raw)
>>> all(m.spec.cell.matches(info.properties.scope, info.properties.density, info.properties.structure) for m in info.usable)
True
Per-column verdicts for a heterogeneous panel:
>>> data = raw.with_columns(pl.col("factor").mean().over("date").alias("macro"))
>>> info = fx.inspect_data(data, factor_cols=["factor", "macro"])
>>> info.factors["factor"].properties.scope.value
'individual'
>>> info.factors["macro"].properties.scope.value
'common'
>>> "ic" in info.factors["macro"].unusable.names
True
Warning declarations¶
inspect_data surfaces every pre-flight advisory both as a structured
Warning record and as a framed UserWarning. Use the same declaration as an
evaluation when a regime is intentional:
import factrix as fx
panel = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
info = fx.inspect_data(
panel,
expected_warnings=("few_assets", "unreliable_se_short_periods"),
)
The records remain in info.warnings, info.factors[col].warnings, and each
metric verdict's warnings, marked with expected=True; only their stderr
echoes stop. Unknown codes are rejected so a typo cannot silently suppress
nothing.
Usability Tiers¶
inspect_data partitions public metrics into three distinct groups based on the inspected data shape and the metric's declarative sample_threshold:
- Usable: The metric is fully applicable and the data shape satisfies all warning thresholds (
warn_*). This is the safest set to run out-of-the-box. - Degraded: The metric is applicable but runs with a warning because the sample size is borderline (falls between the hard
min_*floor and the softwarn_*threshold). - Unusable: The metric cannot be run on this data, either because of a cell mismatch or because a hard sample floor (
min_*) is violated.
Scalar-input helpers such as breakeven_cost and net_spread are also listed
as unusable for panel data. They consume already computed scalar values
(quantile_spread.value, notional_turnover.value) rather than a panel, so run
the upstream diagnostics first and call the helper directly.
Resolved floor for a configured metric¶
The tiers above read each metric's default-configuration sample_threshold
(as do list_metrics / metrics_summary). The floor a run gates on can differ:
the configuration changes it (ic(inference=NEWEY_WEST) needs 20 periods, the
default non-overlapping t-test 50), and stride-scaled floors follow the panel's
overlap_periods (positive_rate() needs 10 periods at overlap_periods=1, 50
at 5). sample_requirements resolves the floor for an instance at a horizon —
the same resolution evaluate and the slice_period_* tests apply — so a
coverage audit (regime slices, IS/OOS splits) can be planned against the
number the run will actually use. evaluate(strict=True) raises on a hard
min_* breach; strict=False short-circuits the metric to NaN with a
metric_unavailable warning; the soft warn_* tier always returns a value
and attaches the axis' degraded-tier warning code.
factrix.sample_requirements ¶
sample_requirements(metric: Any, *, data: DataFrame | None = None, overlap_periods: int | None = None) -> SampleThreshold
Resolve a configured metric's sample floor at a panel's overlap.
The floor a metric gates on depends on its configuration (e.g.
ic(inference=NeweyWest()) needs fewer periods than the default
non-overlapping t-test) and, for metrics that sub-sample at the overlap
stride, on the panel's overlap_periods. list_metrics /
metrics_summary / inspect_data report the default-configuration
floor; this is the run-time one — the same resolution :func:evaluate
and the slice_period_* tests apply.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
metric
|
Any
|
A metric instance ( |
required |
data
|
DataFrame | None
|
Panel whose stamped overlap (set by
:func: |
None
|
overlap_periods
|
int | None
|
The evaluation-grid overlap, when no panel is at
hand (or the panel is unstamped). Validated exactly as
:func: |
None
|
Returns:
| Type | Description |
|---|---|
SampleThreshold
|
class: |
SampleThreshold
|
floors per axis. |
SampleThreshold
|
breach; |
SampleThreshold
|
|
SampleThreshold
|
and attaches the axis' degraded-tier warning code. |
Raises:
| Type | Description |
|---|---|
UserInputError
|
|
Examples:
>>> import factrix as fx
>>> from factrix.metrics import ic, positive_rate
>>> fx.sample_requirements(ic()).min_periods
50
>>> fx.sample_requirements(ic(inference=fx.inference.NEWEY_WEST)).min_periods
20
>>> fx.sample_requirements(positive_rate(), overlap_periods=1).min_periods
10
>>> raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
>>> panel = fx.preprocess.compute_forward_return(raw, forward_periods=5)
>>> fx.sample_requirements(positive_rate(), data=panel).min_periods
50
Scope detection and missing cells¶
FactorScope asks one question: does the factor take the same value across
every asset at a given period (COMMON, a broadcast macro series) or vary
across the cross-section (INDIVIDUAL)?
The question is answered from finite cells only. A null, NaN or ±inf
factor cell is a missing observation, not a distinct factor value, so it cannot
manufacture cross-sectional variation:
- A broadcast factor with a gap on one asset stays
COMMON. - A period left with a single finite cell is still compatible with
COMMON— one observation cannot contradict a broadcast structure. - An asset with no row at all on a period is likewise not variation.
- A period with no finite cell carries no evidence either way and is
ignored.
properties.scope_reasonreports how many of the panel's periods the decision was read from, so an ignored period is visible rather than silent.
If every period is unidentifiable — the column has no finite cell anywhere —
the broadcast property cannot be established at all. Routing then falls back to
the unrestricted FactorScope.INDIVIDUAL (COMMON is its special case, and
claiming it would assert a structure no observation supports) and a
factor_scope_unidentifiable data-level warning is emitted. Every sample floor
is violated at n_pairs = 0 in that state, so read the warning as a missing
factor column rather than as a scope verdict.
evaluate dispatches each factor column through the same detector, so the
pre-flight scope and the cell a run is routed to cannot disagree.
Result structure¶
inspect_data returns a DataInspection carrying the detected data
properties (properties), the per-metric applicability verdicts
(metrics, plus the usable / degraded / unusable partitions), and
any data-level warnings. Each entry in the metrics group is a
MetricApplicability.
The tier groups are MetricApplicabilityGroup objects: they expose .names
and .to_metrics_dict(), and slicing or concatenating them with + preserves
those helpers.
Multi-factor input: per-factor results¶
evaluate dispatches each factor column independently — one
EvaluationResult per column, each routed to its own (scope, density,
structure) cell. Inspection reports at the same granularity, so a preflight
verdict maps one-to-one onto the evaluate output it predicts:
inspect_data(data, factor_cols=cols) |
evaluate(data, ..., factor_cols=cols) |
|---|---|
info.factors[col].properties |
the cell results[col] was dispatched to |
info.factors[col].usable / .degraded / .unusable |
which entries of results[col].metrics return a value, a degraded value, or a metric_unavailable NaN |
info.factors[col].warnings |
the data-level warnings that column raises |
info.factors[col].usable.to_metrics_dict() |
the metrics= argument to run that column safely |
import factrix as fx
import polars as pl
from factrix.preprocess import compute_forward_return
raw = fx.datasets.make_cs_panel(n_assets=20, n_dates=120)
data = compute_forward_return(
raw.with_columns(pl.col("factor").mean().over("date").alias("macro")),
forward_periods=5,
)
info = fx.inspect_data(data, factor_cols=["factor", "macro"])
for col, f in info.factors.items():
print(col, f.properties.scope.value, f.properties.density.value, f.usable.names)
# Run each column with the metrics its own verdict cleared. strict=False keeps
# a metric whose run-time floor binds tighter than the pre-flight one as a NaN
# placeholder with a reason, rather than raising for the whole batch.
for col, f in info.factors.items():
results = fx.evaluate(
data,
metrics=f.usable.to_metrics_dict(),
factor_cols=[col],
forward_periods=5,
strict=False,
)
Two granularities travel together on purpose. A screen over dozens of
candidate columns still wants one answer to "what is this panel", so
properties, metrics and the tier partitions remain a concise aggregate and
describe the first inspected column — exactly factors[<first column>].
Single-factor callers therefore never touch the mapping; multi-factor callers
never have to guess which column an aggregate refers to.
When columns disagree on FactorScope or FactorDensity, the aggregate
warnings carry a cross_factor_*_mismatch naming the basis column, the
columns that disagree with it, and the value each one carries. A later
column's own advisories (low cardinality, frequent events, scope
unidentifiable, sample shape) live on factors[col].warnings rather than
being merged into one stream where the column they belong to would be lost.
One panel-level caveat: n_periods and n_assets are properties of the data,
so every FactorInspection reports the panel's counts rather than the periods
that column happens to cover. Where a column's own coverage gates a metric it
does so through the stage-one profile, which is per column — a column whose
IC cross-sections survive on 20 periods is blocked at ic's 50-period floor
while its sibling in the same panel is not. The same rule applies to
time-series-first common betas: their n_assets floors use only the per-asset
regressions that survive compute_common_betas' complete-pair, minimum-history,
and factor-variation filters, rather than the raw panel universe. A
metric whose run-time gate is data content rather than sample shape is
mirrored the same way: common_quantile_spread is reported unusable when the
per-period factor history carries fewer than n_groups * 2 distinct values,
the comparison it short-circuits insufficient_factor_variation on.
factrix.DataInspection
dataclass
¶
DataInspection(properties: DataProperties, metrics: list[MetricApplicability], warnings: list[Warning] = list(), factors: dict[str, FactorInspection] = dict())
Bases: _MetricPartitionView
Result of :func:inspect_data.
Pure data — no execution methods.
Two granularities travel together. :attr:factors carries one
:class:FactorInspection per inspected column — the granularity
evaluate dispatches at. :attr:properties / :attr:metrics and the
:attr:usable / :attr:degraded / :attr:unusable partitions are the
concise aggregate view and describe the first inspected column, which
is the whole panel when there is only one; they are exactly
factors[<first column>], so a single-factor caller never has to reach
into the mapping.
Attributes:
| Name | Type | Description |
|---|---|---|
properties |
DataProperties
|
:class: |
metrics |
list[MetricApplicability]
|
Flat |
warnings |
list[Warning]
|
Data-level sample-shape diagnostics (NW HAC SE
unreliable, cross-asset df low). |
factors |
dict[str, FactorInspection]
|
|
to_dict ¶
JSON-friendly nested dict view.
Layout (top-level keys, stable order):
properties:{scope, density, structure, n_assets, n_periods, n_pairs, sparse_ratio}— enum fields rendered as their.valuestring;sparse_ratioNaNemitted asNone.reasoning:{scope, density, structure}.metrics: list of per-spec dicts{name, cell, usable, warnings, blockers}— same row shape suitspl.from_dictsfor cross-data audit.warnings: data-level[{code, source, message, expected}, ...].factors:{column name: FactorInspection.to_dict()}in inspected order — the same four keys per column, plusfactor.factors[<first column>]restates the four keys above.
Mirrors :meth:factrix.EvaluationResult.to_dict shape — single
to_dict convention across the public result-type group so
log / parquet sinks treat them uniformly.
factrix.FactorInspection
dataclass
¶
FactorInspection(factor: str, properties: DataProperties, metrics: list[MetricApplicability], warnings: list[Warning] = list())
Bases: _MetricPartitionView
Pre-flight result for one factor column.
evaluate dispatches every factor column independently, so inspection
reports at the same granularity: one of these per inspected column, keyed
by column name on :attr:DataInspection.factors. Every axis, count,
stage-one profile, advisory warning and metric verdict here is computed
from :attr:factor alone — nothing is inherited from a sibling column.
Attributes:
| Name | Type | Description |
|---|---|---|
factor |
str
|
The inspected column's name. |
properties |
DataProperties
|
:class: |
metrics |
list[MetricApplicability]
|
One :class: |
warnings |
list[Warning]
|
Data-level diagnostics raised by this column (sample shape, scope unidentifiable, low-cardinality / frequent-event advisories, single-asset event data). |
to_dict ¶
JSON-friendly view: {factor, properties, reasoning, metrics, warnings}.
Same row shape as :meth:DataInspection.to_dict minus factors,
plus the column name, so a multi-factor audit flattens straight into
pl.from_dicts.
factrix.DataProperties
dataclass
¶
DataProperties(scope: FactorScope, scope_reason: str, density: FactorDensity, density_reason: str, structure: DataStructure, structure_reason: str, n_assets: int, n_periods: int, n_pairs: int, n_events: int, sparse_ratio: float)
Inspected data properties driving cell dispatch.
Carries the dispatch axes (scope / density / structure
as typed enums), the human-readable rationale for each axis decision
(scope_reason / density_reason / structure_reason), and
the data-shape numerics the user typically wants next to them
(n_assets / n_periods / n_pairs / sparse_ratio).
Named Properties rather than Axes because the numeric fields
are not axes — they are observations supporting the axis decisions.
Each *_reason sits next to the enum it explains so detection
verdict and rationale travel together as one value.
Attributes:
| Name | Type | Description |
|---|---|---|
scope |
FactorScope
|
Detected :class: |
scope_reason |
str
|
Human-readable rationale for |
density |
FactorDensity
|
Detected :class: |
density_reason |
str
|
Human-readable rationale for |
structure |
DataStructure
|
Detected :class: |
structure_reason |
str
|
Human-readable rationale for |
n_assets |
int
|
Unique |
n_periods |
int
|
Unique |
n_pairs |
int
|
Finite |
n_events |
int
|
Finite, non-zero |
sparse_ratio |
float
|
Zero-ratio in the |
factrix.MetricApplicabilityGroup ¶
Bases: list['MetricApplicability']
A tier partition of :class:MetricApplicability verdicts.
A list subclass — every list operation works — with discovery
helpers layered on. Returned by :attr:DataInspection.usable /
degraded / unusable.
to_metrics_dict ¶
Normalise the group into {name: metric_instance} for evaluate.
The canonical discovery bridge: pick a tier (usually usable) and
feed its default-constructed instances straight to
:func:factrix.evaluate::
info = fx.inspect_data(data)
results = fx.evaluate(data, metrics=info.usable.to_metrics_dict(),
factor_cols=[...], forward_periods=5)
Scalar-input utilities are kept out of usable and therefore out
of this bridge: compute their upstream values first, then call the
helper directly.
factrix.MetricApplicability
dataclass
¶
MetricApplicability(metric: type[MetricBase], name: str, spec: MetricSpec, usable: bool, warnings: list[Warning] = list(), blockers: list[str] = list())
Per-metric pre-flight verdict against one data's properties.
Attributes:
| Name | Type | Description |
|---|---|---|
metric |
type[MetricBase]
|
The :class: |
name |
str
|
The metric's registry name ( |
spec |
MetricSpec
|
The :class: |
usable |
bool
|
|
warnings |
list[Warning]
|
|
blockers |
list[str]
|
Concrete reasons the metric is unusable
(cell mismatch, |