# (c) Kevin Dunn, 2010-2026. MIT License.
"""Analysis of data from OMARS designs (orthogonal minimally aliased response surface).
OMARS designs are three-level response surface designs in which the main
effects are mutually orthogonal and orthogonal to the second-order effects,
while the second-order effects (two-factor interactions and quadratic terms)
are only *minimally* aliased with one another. This orthogonality structure
permits a staged analysis that first resolves the main effects and then,
using a pooled estimate of the error variance, searches the second-order
space for the small number of active interaction and quadratic terms.
:func:`analyze_omars` implements that staged protocol. It does not require
the design to have been produced by any particular generator, but the protocol
is valid only for a design with the OMARS structure: main effects that are
balanced, mutually orthogonal, and orthogonal to every second-order term. The
main effects are estimated without adjusting for the second-order terms, and
the pooled error and the second-order projection both rely on that
orthogonality, so on any other design (a three-level design with runs missing,
a Plackett-Burman design) a pure quadratic effect leaks into the main-effect
tests. :func:`analyze_omars` checks the structure and warns when it is absent.
The procedure, summarised:
1. Code each factor to the ``[-1, +1]`` range and centre the response.
2. Estimate the error variance from the residuals of the full second-order
model (intercept + main effects + all quadratics and interactions).
3. Test the main effects with t-tests at ``alpha_main`` and split them into
active and inactive sets.
4. Pool the inactive main effects back into the error estimate, increasing
the error degrees of freedom and (usually) sharpening the variance.
5. Gate the second-order space with an overall F-test at
``alpha_second_overall``.
6. If the gate passes, build a candidate set of second-order terms subject to
the requested heredity rules, then run a best-subset search, adding terms
until the residual second-order variation is no longer significant at
``alpha_second_subset``. The search is exhaustive at every subset size with
at most 200,000 subsets; past that it continues by a beam search refined by
sequential replacement, so a design with many factors finishes in seconds
rather than hours.
References
----------
The staged analysis generalises the design-based model-selection approach for
definitive screening designs to the wider OMARS family. This is an independent
implementation of that published methodology.
* Jones, B. and Nachtsheim, C. J. (2017). "Effective Design-Based Model
Selection for Definitive Screening Designs." Technometrics 59(3), 319-329.
* Ismail Hameed, M. S., Nunez Ares, J. and Goos, P. (2023). "Analysis of data
from orthogonal minimally aliased response surface designs." Journal of
Quality Technology 55(3).
* Nunez Ares, J. and Goos, P. (2020). "Enumeration and Multicriteria Selection
of Orthogonal Minimally Aliased Response Surface Designs." Technometrics
62(1), 21-36.
"""
from __future__ import annotations
import heapq
import itertools
import math
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
import numpy as np
import pandas as pd
from process_improve._warnings import user_stacklevel
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
# Quadratic-heredity options.
_Q_HEREDITY = ("none", "strong")
# Interaction-heredity options.
_I_HEREDITY = ("none", "weak", "strong")
#: Most subsets of one size the search fits one by one (a few seconds of least squares). A 10-factor
#: design with no heredity has 55 candidates and C(55, 6) = 29 million subsets of six, which is hours.
_EXHAUSTIVE_SUBSETS = 200_000
#: How many of the best subsets of one size the search extends to the next, once it is past exhaustive.
_BEAM = 100
[docs]
@dataclass
class OmarsResult:
"""Container returned by :func:`analyze_omars`.
Attributes
----------
success : bool
``False`` when the full second-order model leaves no error degrees of
freedom (the design cannot support the analysis). All other fields are
only meaningful when ``success`` is ``True``.
initial_error_df : int
Error degrees of freedom from the full second-order model (step 2).
initial_rmse : float
Square root of the initial error-variance estimate (step 2).
active_main_effects : list[str]
Factor names declared active by the main-effect t-tests (step 3),
including any forced in via ``force_main_effects``.
forced_main_effects : list[str]
Factor names that were forced active despite being statistically
inactive.
main_effect_p_values : dict[str, float]
Two-sided p-value for each factor's main effect.
updated_error_df : int
Error degrees of freedom after pooling inactive main effects (step 4).
updated_rmse : float
Square root of the pooled error-variance estimate (step 4).
second_order_overall_p_value : float
p-value of the overall second-order F-gate (step 5). ``nan`` when the
analysis was main-effects only.
active_interactions : list[str]
Two-factor interaction terms declared active, labelled ``"A:B"``.
active_quadratics : list[str]
Quadratic terms declared active, labelled ``"A^2"``.
final_p_value : float
p-value of the final (failed) F-test that terminated the subset search.
``nan`` when the second-order gate did not open.
subset_limit : int
Maximum number of second-order terms the subset search was allowed to
consider.
subset_limit_reason : str
Human-readable explanation of how ``subset_limit`` was chosen.
details : dict[str, Any]
Additional diagnostic values (term ranks, candidate counts, ...).
``details["subset_search"]`` says whether every subset size the search
reached was searched exhaustively, or from which size it continued by
a beam search refined by sequential replacement.
"""
success: bool = False
initial_error_df: int = 0
initial_rmse: float = float("nan")
active_main_effects: list[str] = field(default_factory=list)
forced_main_effects: list[str] = field(default_factory=list)
main_effect_p_values: dict[str, float] = field(default_factory=dict)
updated_error_df: int = 0
updated_rmse: float = float("nan")
second_order_overall_p_value: float = float("nan")
active_interactions: list[str] = field(default_factory=list)
active_quadratics: list[str] = field(default_factory=list)
final_p_value: float = float("nan")
subset_limit: int = 0
subset_limit_reason: str = ""
details: dict[str, Any] = field(default_factory=dict)
def _code_design(matrix: np.ndarray) -> np.ndarray:
"""Code each column to the ``[-1, +1]`` range from its own min and max.
A column with levels ``{lo, mid, hi}`` is mapped with centre
``(hi + lo) / 2`` and half-range ``(hi - lo) / 2`` so that the extreme
levels become ``-1`` and ``+1`` and any symmetric middle level becomes
``0``.
"""
coded = np.empty_like(matrix, dtype=float)
for j in range(matrix.shape[1]):
col = matrix[:, j]
hi, lo = col.max(), col.min()
half = (hi - lo) / 2.0
if half == 0:
msg = f"Factor column {j} is constant; it has no range to code."
raise ValueError(msg)
coded[:, j] = (col - (hi + lo) / 2.0) / half
return coded
def _orthogonality(coded: np.ndarray, full_so: np.ndarray, tol: float = 1e-8) -> dict[str, bool]:
"""Check the structure the staged analysis relies on, for the coded design.
Returns whether the main effects are balanced (each column sums to zero), mutually
orthogonal, and orthogonal to every (centred) second-order column.
"""
n_runs = coded.shape[0]
scale = tol * n_runs
cross = coded.T @ coded
return {
"main_effects_balanced": bool(np.all(np.abs(coded.sum(axis=0)) <= scale)),
"main_effects_orthogonal": bool(np.all(np.abs(cross - np.diag(np.diag(cross))) <= scale)),
"main_effects_clear_of_second_order": bool(np.all(np.abs(coded.T @ full_so) <= scale))
if full_so.shape[1]
else True,
}
def _validate_options(
alphas: dict[str, float],
max_subset_terms: int | None,
quadratic_heredity: str,
interaction_heredity: str,
) -> None:
"""Raise ``ValueError`` for an option outside its range."""
for name, value in alphas.items():
if not 0.0 < value < 1.0:
msg = f"{name} must lie strictly between 0 and 1 (for 5% pass 0.05), got {value!r}."
raise ValueError(msg)
if max_subset_terms is not None and max_subset_terms < 0:
msg = f"max_subset_terms must be zero or more, or None to let the routine choose; got {max_subset_terms!r}."
raise ValueError(msg)
if quadratic_heredity not in _Q_HEREDITY:
msg = f"quadratic_heredity must be one of {_Q_HEREDITY}, got {quadratic_heredity!r}."
raise ValueError(msg)
if interaction_heredity not in _I_HEREDITY:
msg = f"interaction_heredity must be one of {_I_HEREDITY}, got {interaction_heredity!r}."
raise ValueError(msg)
def _canonical_drop_labels(effects_to_drop: list[str], names: list[str]) -> set[str]:
"""Check ``effects_to_drop`` against every second-order label of the design's factors.
Quadratics are ``"A^2"``; an interaction may be given in either order (``"B:A"`` is
``"A:B"``) and is returned in the design's column order. A label naming no
second-order term of these factors raises ``ValueError``, whatever the data.
"""
position = {name: j for j, name in enumerate(names)}
canonical: set[str] = set()
unknown: list[str] = []
for label in effects_to_drop:
if label.endswith("^2") and label[:-2] in position:
canonical.add(label)
continue
parts = label.split(":")
if len(parts) == 2 and all(p in position for p in parts) and parts[0] != parts[1]:
first, second = sorted(parts, key=position.__getitem__)
canonical.add(f"{first}:{second}")
continue
unknown.append(label)
if unknown:
msg = (
f"effects_to_drop names unknown second-order terms {unknown}; use 'A^2' for a quadratic and "
f"'A:B' for an interaction, over the factors {names}."
)
raise ValueError(msg)
return canonical
def _hat(matrix: np.ndarray) -> np.ndarray:
"""Orthogonal projection (hat) matrix onto the column space of ``matrix``."""
return matrix @ np.linalg.pinv(matrix.T @ matrix) @ matrix.T
def _quadratic_columns(coded: np.ndarray) -> list[int]:
"""Return indices of columns carrying a third (middle) level, i.e. quadratic-capable."""
return [j for j in range(coded.shape[1]) if np.any(np.abs(coded[:, j]) != 1)]
def _full_second_order(coded: np.ndarray, quad_cols: list[int]) -> np.ndarray:
"""Return the centred matrix of all quadratic and two-factor interaction columns."""
n_runs, n_factors = coded.shape
quad_blocks = [(coded[:, j] ** 2).reshape(n_runs, 1) for j in quad_cols]
inter_blocks = [
(coded[:, a] * coded[:, b]).reshape(n_runs, 1) for a, b in itertools.combinations(range(n_factors), 2)
]
blocks = quad_blocks + inter_blocks
if not blocks:
return np.empty((n_runs, 0))
full = np.hstack(blocks)
return full - full.mean(axis=0)
def _has_omars_structure(matrix: np.ndarray) -> bool:
"""Say whether a design (one column per factor) has every part of the structure the staged analysis needs."""
coded = _code_design(matrix)
return all(_orthogonality(coded, _full_second_order(coded, _quadratic_columns(coded))).values())
def _heredity_candidates(
coded: np.ndarray,
names: list[str],
quad_cols: list[int],
active_idx: list[int],
inactive_idx: list[int],
q_heredity: str,
i_heredity: str,
) -> tuple[np.ndarray, list[str]]:
"""Build the centred candidate second-order matrix under heredity rules.
Returns the matrix of candidate columns and the matching list of effect
labels (``"A^2"`` for quadratics, ``"A:B"`` for interactions).
"""
n_runs, n_factors = coded.shape
columns: list[np.ndarray] = []
labels: list[str] = []
quad_set = set(quad_cols)
# When no main effects are active the heredity rules cannot bite.
if not active_idx:
q_heredity, i_heredity = "none", "none"
# Quadratic candidates.
quad_candidates = [j for j in active_idx if j in quad_set] if q_heredity == "strong" else list(quad_cols)
for j in quad_candidates:
columns.append((coded[:, j] ** 2).reshape(n_runs, 1))
labels.append(f"{names[j]}^2")
# Interaction candidates.
if i_heredity == "strong":
pairs = list(itertools.combinations(sorted(active_idx), 2))
elif i_heredity == "weak":
pairs = list(itertools.combinations(sorted(active_idx), 2))
pairs += [(min(a, b), max(a, b)) for a in active_idx for b in inactive_idx]
else:
pairs = list(itertools.combinations(range(n_factors), 2))
for a, b in pairs:
columns.append((coded[:, a] * coded[:, b]).reshape(n_runs, 1))
labels.append(f"{names[a]}:{names[b]}")
if not columns:
return np.empty((n_runs, 0)), []
candidate = np.hstack(columns)
candidate = candidate - candidate.mean(axis=0)
return candidate, labels
def _subset_limit(
n_runs: int,
n_factors: int,
full_so_rank: int,
candidate_rank: int,
n_candidates: int,
user_limit: int | None,
) -> tuple[int, str]:
"""Decide how many second-order terms the subset search may consider."""
total_so_terms = n_factors * (n_factors + 1) // 2
if user_limit is not None:
limit = min(user_limit, candidate_rank)
return limit, f"user specified ({user_limit})"
if total_so_terms == full_so_rank:
return full_so_rank, "all second-order terms are jointly estimable"
n_by_4 = n_runs // 4
if n_candidates <= full_so_rank or candidate_rank <= n_by_4:
return candidate_rank, "maximum jointly estimable second-order terms"
return n_by_4, "run size divided by four"
def _residual_ss(design: np.ndarray, response: np.ndarray) -> float:
"""Residual sum of squares of ``response`` regressed on ``design``."""
beta, _, _, _ = np.linalg.lstsq(design, response, rcond=None)
residual = response - design @ beta
return float((residual.T @ residual).item())
def _best_subset_search( # noqa: PLR0913
candidate: np.ndarray,
labels: list[str],
cy_second: np.ndarray,
error_variance: float,
error_df: int,
full_so_rank: int,
limit: int,
alpha: float,
) -> tuple[list[str], float, int]:
"""Best-subset search over the second-order candidates.
Grows the subset one term at a time, keeping the lowest-RSS subset of the
current size, and stops once the residual second-order variation is no
longer significant at ``alpha`` (or the candidates are exhausted). A size
with at most :data:`_EXHAUSTIVE_SUBSETS` subsets is searched exhaustively.
A larger size is searched by a beam: every one-term extension of the
:data:`_BEAM` best subsets of the size before, the best of which is then
refined by :func:`_swap_terms`. Returns the active labels, the p-value of
the terminating F-test, and the largest size searched exhaustively.
"""
from scipy.stats import f as f_dist # noqa: PLC0415 - local import keeps scipy optional
n_runs, n_candidates = candidate.shape
intercept = np.ones((n_runs, 1))
def rss(combo: tuple[int, ...]) -> float:
return _residual_ss(np.hstack((intercept, candidate[:, combo])), cy_second)
beam: list[tuple[float, tuple[int, ...]]] = [(0.0, ())]
best_combo: tuple[int, ...] = ()
final_p_value = float("nan")
exhaustive_to = 0
for size in range(1, limit + 1):
if math.comb(n_candidates, size) <= _EXHAUSTIVE_SUBSETS:
exhaustive_to = size
subsets: Iterable[tuple[int, ...]] = itertools.combinations(range(n_candidates), size)
else:
subsets = sorted({tuple(sorted((*s, j))) for _, s in beam for j in range(n_candidates) if j not in s})
# Ties go to the subset first in lexicographic order, the one a plain scan would keep.
beam = heapq.nsmallest(_BEAM, ((rss(combo), combo) for combo in subsets))
best_rss, best_combo = beam[0]
if size > exhaustive_to:
best_rss, best_combo = _swap_terms(rss, n_candidates, best_rss, best_combo)
beam.insert(0, (best_rss, best_combo))
df_left = full_so_rank - size
if df_left <= 0:
break
f_stat = (best_rss / df_left) / error_variance
f_crit = f_dist.ppf(1 - alpha, dfn=df_left, dfd=error_df)
final_p_value = float(1 - f_dist.cdf(f_stat, df_left, error_df))
if size == n_candidates or f_stat < f_crit:
break
active = [labels[i] for i in best_combo]
return active, final_p_value, exhaustive_to
def _swap_terms(
rss: Callable[[tuple[int, ...]], float], n_candidates: int, best_rss: float, best: tuple[int, ...]
) -> tuple[float, tuple[int, ...]]:
"""Refine a subset by sequential replacement, and return its RSS and the subset.
Makes the best single swap of a term in the subset for one outside it, until
no swap lowers the RSS (Miller, *Subset Selection in Regression*).
"""
while True:
swaps = [(*best[:i], j, *best[i + 1 :]) for i in range(len(best)) for j in range(n_candidates) if j not in best]
trial_rss, trial = min(((rss(t), tuple(sorted(t))) for t in swaps), default=(np.inf, best))
if not trial_rss < best_rss * (1 - 1e-12): # only a strict drop counts, so ties cannot cycle
return best_rss, best
best_rss, best = trial_rss, trial
[docs]
def analyze_omars( # noqa: C901, PLR0911, PLR0912, PLR0913, PLR0915
design_matrix: pd.DataFrame,
response: pd.Series | pd.DataFrame | np.ndarray,
*,
alpha_main: float = 0.05,
alpha_second_overall: float = 0.20,
alpha_second_subset: float = 0.20,
quadratic_heredity: str = "none",
interaction_heredity: str = "none",
effects_to_drop: list[str] | None = None,
force_main_effects: list[str] | None = None,
second_order: bool = True,
max_subset_terms: int | None = None,
) -> OmarsResult:
"""Analyse data from an OMARS design with the staged main-then-second-order protocol.
Parameters
----------
design_matrix : DataFrame
One column per factor, one row per run. Factors must be quantitative
with two or three levels; they are coded internally to ``[-1, +1]``.
Do not include second-order columns; they are constructed internally.
response : Series, DataFrame, or ndarray
The (single) response, one value per run.
alpha_main : float, optional
Significance level for the main-effect t-tests (step 3). Default 0.05.
This and the other two levels must lie strictly between 0 and 1.
alpha_second_overall : float, optional
Significance level for the overall second-order F-gate (step 5).
Default 0.20.
alpha_second_subset : float, optional
Significance level for the subset-search F-tests (step 6).
Default 0.20.
quadratic_heredity : {"none", "strong"}, optional
Heredity rule for quadratic terms. ``"strong"`` only considers the
quadratic of a factor whose main effect is active. Default ``"none"``.
interaction_heredity : {"none", "weak", "strong"}, optional
Heredity rule for two-factor interactions. ``"strong"`` requires both
parent main effects to be active; ``"weak"`` requires at least one;
``"none"`` considers all interactions. Default ``"none"``.
effects_to_drop : list of str, optional
Second-order effect labels to exclude from the candidate set, e.g.
``["A^2", "B:C"]`` (``"C:B"`` means the same). Every label is checked
against the design's factors before the analysis starts.
force_main_effects : list of str, optional
Factor names to force into the active set even if statistically
inactive. Useful for borderline effects.
second_order : bool, optional
When ``False`` the analysis stops after the main effects (step 3).
Default ``True``.
max_subset_terms : int, optional
Hard cap on the number of second-order terms the subset search may
consider, zero or more. ``None`` (default) lets the routine choose a
cap from the design's estimability.
Returns
-------
OmarsResult
Structured analysis result. See the class documentation for fields.
Raises
------
ValueError
If inputs are malformed (non-numeric, contain missing values,
mismatched lengths, unknown heredity option, unknown effect label, a
significance level outside (0, 1), or a negative ``max_subset_terms``).
Warns
-----
RuntimeWarning
When the design's main effects are not balanced, mutually orthogonal
and orthogonal to the second-order terms, which the staged protocol
needs. ``details["design_structure"]`` records each of the three checks.
Examples
--------
>>> import pandas as pd
>>> from process_improve.experiments import analyze_omars
>>> result = analyze_omars(design, y, quadratic_heredity="strong") # doctest: +SKIP
>>> result.active_main_effects # doctest: +SKIP
['A', 'C']
"""
_validate_options(
{
"alpha_main": alpha_main,
"alpha_second_overall": alpha_second_overall,
"alpha_second_subset": alpha_second_subset,
},
max_subset_terms,
quadratic_heredity,
interaction_heredity,
)
from scipy.stats import f as f_dist # noqa: PLC0415
from scipy.stats import t as t_dist # noqa: PLC0415
effects_to_drop = list(effects_to_drop or [])
force_main_effects = list(force_main_effects or [])
names = [str(c) for c in design_matrix.columns]
raw = np.asarray(design_matrix.to_numpy(), dtype=float)
y = np.asarray(response).reshape(-1)
if raw.shape[0] != y.shape[0]:
msg = f"design_matrix has {raw.shape[0]} rows but response has {y.shape[0]} values."
raise ValueError(msg)
if not np.isfinite(raw).all() or not np.isfinite(y).all():
msg = "design_matrix and response must not contain missing or infinite values."
raise ValueError(msg)
drop_labels = _canonical_drop_labels(effects_to_drop, names)
coded = _code_design(raw)
n_runs, n_factors = coded.shape
cy = (y - y.mean()).reshape(n_runs, 1)
quad_cols = _quadratic_columns(coded)
full_so = _full_second_order(coded, quad_cols)
structure = _orthogonality(coded, full_so)
if not all(structure.values()):
failed = ", ".join(key.replace("_", " ") for key, ok in structure.items() if not ok)
warnings.warn(
f"The design does not have the OMARS structure the staged analysis needs (not: {failed}). The "
"main-effect tests and the second-order search are then not valid: second-order effects leak "
"into the main effects. Fit the full second-order model by least squares instead.",
category=RuntimeWarning,
stacklevel=user_stacklevel(),
)
intercept = np.ones((n_runs, 1))
total = np.hstack((intercept, coded, full_so))
error_projection = np.identity(n_runs) - _hat(total)
error_df = n_runs - np.linalg.matrix_rank(total)
result = OmarsResult()
result.details["design_structure"] = structure
if error_df <= 0:
result.success = False
result.details["reason"] = "full second-order model leaves no error degrees of freedom"
return result
variance = float((cy.T @ error_projection @ cy).item()) / error_df
if variance <= 0:
result.success = False
result.details["reason"] = (
"error variance is zero; the full model fits perfectly so no significance tests are possible"
)
return result
result.success = True
result.initial_error_df = int(error_df)
s = float(np.sqrt(variance))
result.initial_rmse = round(s, 6)
# --- Step 3: main-effect t-tests -------------------------------------
xtx_inv = np.linalg.pinv(coded.T @ coded)
beta_me = (xtx_inv @ coded.T @ cy).reshape(n_factors)
se_me = np.sqrt(np.diag(xtx_inv))
t_values = np.abs(beta_me / (s * se_me))
t_crit = t_dist.ppf(1 - alpha_main / 2, df=error_df)
p_values = 2 * (1 - t_dist.cdf(t_values, error_df))
active_idx = [j for j in range(n_factors) if t_values[j] >= t_crit]
result.main_effect_p_values = {names[j]: float(round(p_values[j], 6)) for j in range(n_factors)}
# Force requested main effects active.
forced: list[str] = []
name_to_idx = {names[j]: j for j in range(n_factors)}
for fname in force_main_effects:
if fname not in name_to_idx:
msg = f"force_main_effects names unknown factor {fname!r}."
raise ValueError(msg)
j = name_to_idx[fname]
if j not in active_idx:
active_idx.append(j)
forced.append(fname)
active_idx.sort()
inactive_idx = [j for j in range(n_factors) if j not in active_idx]
result.active_main_effects = [names[j] for j in active_idx]
result.forced_main_effects = forced
if not second_order:
result.updated_error_df = int(error_df)
result.updated_rmse = result.initial_rmse
return result
# --- Step 4: pool inactive main effects into the error estimate ------
inactive_projection = _hat(coded[:, inactive_idx]) if inactive_idx else np.zeros((n_runs, n_runs))
pooled_projection = error_projection + inactive_projection
pooled_df = error_df + len(inactive_idx)
pooled_variance = float((cy.T @ pooled_projection @ cy).item()) / pooled_df
result.updated_error_df = int(pooled_df)
result.updated_rmse = round(float(np.sqrt(pooled_variance)), 6)
# --- Step 5: overall second-order F-gate -----------------------------
second_projection = np.identity(n_runs) - _hat(coded) - error_projection
cy_second = (second_projection @ cy).reshape(n_runs, 1)
tss = float((cy_second.T @ cy_second).item())
full_so_rank = int(np.linalg.matrix_rank(full_so)) if full_so.shape[1] else 0
result.details["full_second_order_rank"] = full_so_rank
result.details["full_second_order_terms"] = int(full_so.shape[1])
if full_so_rank == 0:
result.second_order_overall_p_value = float("nan")
return result
f_stat = (tss / full_so_rank) / pooled_variance
f_crit = f_dist.ppf(1 - alpha_second_overall, dfn=full_so_rank, dfd=pooled_df)
result.second_order_overall_p_value = round(float(1 - f_dist.cdf(f_stat, full_so_rank, pooled_df)), 6)
if f_stat < f_crit:
# Gate stays shut: no active second-order effects.
return result
# --- Step 6: heredity-constrained best-subset search -----------------
candidate, labels = _heredity_candidates(
coded, names, quad_cols, active_idx, inactive_idx, quadratic_heredity, interaction_heredity
)
if drop_labels:
keep = [i for i, lbl in enumerate(labels) if lbl not in drop_labels]
candidate = candidate[:, keep]
labels = [labels[i] for i in keep]
if candidate.shape[1] == 0:
return result
candidate_rank = int(np.linalg.matrix_rank(candidate))
limit, reason = _subset_limit(n_runs, n_factors, full_so_rank, candidate_rank, candidate.shape[1], max_subset_terms)
result.subset_limit = int(limit)
result.subset_limit_reason = reason
result.details["candidate_terms"] = int(candidate.shape[1])
result.details["candidate_rank"] = candidate_rank
if limit <= 0:
return result
active, final_p, exhaustive_to = _best_subset_search(
candidate,
labels,
cy_second,
pooled_variance,
pooled_df,
full_so_rank,
limit,
alpha_second_subset,
)
result.details["subset_search"] = (
"exhaustive"
if exhaustive_to >= len(active)
else f"exhaustive up to {exhaustive_to} terms, then a beam search refined by sequential replacement (too "
"many subsets to fit each)"
)
result.final_p_value = round(final_p, 6) if np.isfinite(final_p) else float("nan")
result.active_interactions = [lbl for lbl in active if ":" in lbl]
result.active_quadratics = [lbl for lbl in active if lbl.endswith("^2")]
return result