Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 6 additions & 3 deletions src/process_improve/experiments/designs_omars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
34 changes: 34 additions & 0 deletions tests/test_experiments_omars.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down