From 9b9431c27835ed9dd98e0d7e629fdd1f001d6022 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 21:59:37 +0000 Subject: [PATCH] fix(experiments): reject an OMARS design that never varies a factor omars_properties checked that each factor reaches the middle level at least once, so that its pure quadratic is not the constant column a two-level factor gives. The mirror case was not checked: a factor left at the middle level in every run has the constant quadratic 0. Both are inestimable, and only the first was being caught. The consequence was worse than a permissive verifier. max_second_order_correlation skips constant columns, which is right on its own terms, but a pinned factor removes its own quadratic and all of its interaction columns from the comparison, so the score improves. generate_omars(selection_criterion= "min_second_order_correlation") minimises exactly that quantity, so it had a direct incentive to produce the degenerate designs the verifier was accepting, and did. Across a three-to-six factor sweep it returned one in roughly a third of cells, including a spurious perfect 0.000 at three factors in nine runs. The fix is to require an outer level as well as a middle one. With the verifier corrected the ILP rejects those candidates during the search and spends its budget on real ones instead: degenerate results fell from twelve to three, and two sizes that previously produced no usable design at all, three factors in nine runs and five factors in thirteen, now produce one. Five runs in three factors is the smallest case that shows the failure and is the new regression test: with the third factor pinned at the centre, is_omars returned True while the main-and-quadratic model matrix had rank 4 of 7. is_omars now returns False for matrices it previously accepted. Every such matrix has an inestimable quadratic, so no correct caller can be relying on the old answer, and it is corrected in place rather than deprecated. Verification: ruff check . and ruff format --check . clean mypy src/process_improve clean, 146 source files pytest -k omars: 291 passed, 2 skipped the 13-run definitive screening design still verifies Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01W1daHPaXLs7PPRsq8CmMMm --- CHANGELOG.md | 21 ++++++++++++ .../experiments/designs_omars.py | 9 +++-- tests/test_experiments_omars.py | 34 +++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c8f0bc1..8b0c15e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ those changes. ## [Unreleased] +### Fixed + +- `omars_properties` and `is_omars` now reject a design that leaves a factor at + the middle level in every run. The check that each factor is genuinely + three-level tested only that the factor reaches the middle level at least + once, which catches a two-level factor (constant quadratic `1`) but not the + mirror case of a factor the design never varies (constant quadratic `0`). + Both are inestimable, and the second was being reported as a valid OMARS + design whose main-and-quadratic model matrix is rank deficient. + + This also removes a bias in the `min_second_order_correlation` selection + criterion of `generate_omars`. `max_second_order_correlation` skips constant + columns, so a factor pinned at the centre removed its own quadratic and all + of its interactions from the comparison and improved the score. The criterion + therefore had an incentive to produce exactly the degenerate designs the + verifier was failing to catch, and did: in a sweep across three to six + factors it returned one, including a spurious perfect `0.000`, in roughly a + third of cells. With the verifier corrected those candidates are rejected + during the search, and two sizes that previously yielded no usable design at + all (three factors in nine runs, five factors in thirteen) now yield one. + ### Added - Minimum moment aberration (Xu, 2003) for two-level designs, as diff --git a/src/process_improve/experiments/designs_omars.py b/src/process_improve/experiments/designs_omars.py index c85fbb8..96bd261 100644 --- a/src/process_improve/experiments/designs_omars.py +++ b/src/process_improve/experiments/designs_omars.py @@ -157,10 +157,13 @@ def omars_properties(matrix: np.ndarray, *, tol: float = _DEFAULT_TOL) -> dict: is_three_level = bool(np.all(np.abs(matrix - nearest) <= tol) and np.all(np.isin(nearest, (-1.0, 0.0, 1.0)))) # OMARS factors are genuinely three-level: each must take the middle (0) - # level at least once, otherwise its pure quadratic is a constant column - # and cannot be estimated (as in a two-level factorial). + # level at least once and an outer level at least once. A pure quadratic is + # a constant column, and so not estimable, in either degenerate case: the + # factor never sits at the middle (x^2 == 1, as in a two-level factorial) or + # it never leaves it (x^2 == 0, a factor the design never actually varies). uses_middle_level = np.any(np.abs(matrix) <= tol, axis=0) - quadratics_estimable = bool(np.all(uses_middle_level)) + uses_outer_level = np.any(np.abs(matrix) > tol, axis=0) + quadratics_estimable = bool(np.all(uses_middle_level & uses_outer_level)) column_sums = np.abs(matrix.sum(axis=0)) is_balanced = bool(np.all(column_sums <= tol)) diff --git a/tests/test_experiments_omars.py b/tests/test_experiments_omars.py index e761b00..7db97a4 100644 --- a/tests/test_experiments_omars.py +++ b/tests/test_experiments_omars.py @@ -64,6 +64,40 @@ def test_full_factorial_is_not_omars(self) -> None: assert props["is_omars"] is False assert is_omars(ff) is False + def test_factor_never_leaving_the_middle_is_not_omars(self) -> None: + """A factor pinned at the centre has a constant quadratic, so it is not OMARS. + + The mirror of :meth:`test_full_factorial_is_not_omars`: a two-level factor + gives the constant quadratic ``1``, and a factor the design never varies + gives the constant quadratic ``0``. Neither is estimable. + """ + # x3 sits at the middle level in every run, so its main effect and its + # quadratic are both identically zero. + pinned = np.array( + [ + [1, 1, 0], + [-1, 1, 0], + [1, -1, 0], + [-1, -1, 0], + [0, 0, 0], + ], + dtype=float, + ) + props = omars_properties(pinned) + assert props["quadratics_estimable"] is False + assert props["is_omars"] is False + assert is_omars(pinned) is False + + # The failure it stands for: the main-and-quadratic model is not estimable, + # even though every other OMARS property is satisfied. + n_runs = pinned.shape[0] + model_matrix = np.column_stack( + [np.ones(n_runs), *pinned.T, *(pinned**2).T], + ) + assert np.linalg.matrix_rank(model_matrix) < model_matrix.shape[1] + assert props["is_balanced"] is True + assert props["main_effects_orthogonal"] is True + def test_main_effect_aliased_with_interaction_fails(self) -> None: """A design whose main effect correlates with an interaction is not OMARS.""" # x3 deliberately equals x1*x2 on the non-zero rows -> ME3 aliased with x1:x2.