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.