Skip to content

Commit

Permalink
Fractfact refactoring (#15)
Browse files Browse the repository at this point in the history
* Refactor of fracfact from bofire

* Make test pass (result reordering)

* Remove unused import
  • Loading branch information
relf authored Aug 16, 2024
1 parent b1b134d commit 5302b5d
Show file tree
Hide file tree
Showing 6 changed files with 131 additions and 76 deletions.
6 changes: 3 additions & 3 deletions pyDOE3/doe_box_behnken.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,16 +45,16 @@ def bbdesign(n, center=None):
>>> bbdesign(3)
array([[-1., -1., 0.],
[ 1., -1., 0.],
[-1., 1., 0.],
[ 1., -1., 0.],
[ 1., 1., 0.],
[-1., 0., -1.],
[ 1., 0., -1.],
[-1., 0., 1.],
[ 1., 0., -1.],
[ 1., 0., 1.],
[ 0., -1., -1.],
[ 0., 1., -1.],
[ 0., -1., 1.],
[ 0., 1., -1.],
[ 0., 1., 1.],
[ 0., 0., 0.],
[ 0., 0., 0.],
Expand Down
8 changes: 4 additions & 4 deletions pyDOE3/doe_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,12 +88,12 @@ def ccdesign(n, center=(4, 4), alpha="orthogonal", face="circumscribed"):
>>> ccdesign(3)
array([[-1. , -1. , -1. ],
[ 1. , -1. , -1. ],
[-1. , 1. , -1. ],
[ 1. , 1. , -1. ],
[-1. , -1. , 1. ],
[ 1. , -1. , 1. ],
[-1. , 1. , -1. ],
[-1. , 1. , 1. ],
[ 1. , -1. , -1. ],
[ 1. , -1. , 1. ],
[ 1. , 1. , -1. ],
[ 1. , 1. , 1. ],
[ 0. , 0. , 0. ],
[ 0. , 0. , 0. ],
Expand Down
129 changes: 82 additions & 47 deletions pyDOE3/doe_factorial.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import numpy as np
from scipy.special import binom


__all__ = [
"fullfact",
"ff2n",
Expand Down Expand Up @@ -97,10 +96,7 @@ def fullfact(levels):
return H


################################################################################


def ff2n(n):
def ff2n(n_factors: int) -> np.ndarray:
"""
Create a 2-Level full-factorial design
Expand All @@ -120,22 +116,67 @@ def ff2n(n):
>>> ff2n(3)
array([[-1., -1., -1.],
[ 1., -1., -1.],
[-1., 1., -1.],
[ 1., 1., -1.],
[-1., -1., 1.],
[ 1., -1., 1.],
[-1., 1., -1.],
[-1., 1., 1.],
[ 1., -1., -1.],
[ 1., -1., 1.],
[ 1., 1., -1.],
[ 1., 1., 1.]])
"""
return 2 * fullfact([2] * n) - 1
return np.array(list(itertools.product([-1.0, 1.0], repeat=n_factors)))


################################################################################
def validate_generator(n_factors: int, generator: str) -> str:
"""Validates the generator and thows an error if it is not valid."""

if len(generator.split(" ")) != n_factors:
raise ValueError("Generator does not match the number of factors.")
# clean it and transform it into a list
generators = [item for item in re.split(r"\-|\s|\+", generator) if item]
lengthes = [len(i) for i in generators]

# Indices of single letters (main factors)
idx_main = [i for i, item in enumerate(lengthes) if item == 1]

if len(idx_main) == 0:
raise ValueError("At least one unconfounded main factor is needed.")

# Check that single letters (main factors) are unique
if len(idx_main) != len({generators[i] for i in idx_main}):
raise ValueError("Main factors are confounded with each other.")

# Check that single letters (main factors) follow the alphabet
if (
"".join(sorted([generators[i] for i in idx_main]))
!= string.ascii_lowercase[: len(idx_main)]
):
raise ValueError(
f'Use the letters `{" ".join(string.ascii_lowercase[: len(idx_main)])}` for the main factors.'
)

# Indices of letter combinations.
idx_combi = [i for i, item in enumerate(generators) if item != 1]

# check that main factors come before combinations
if min(idx_combi) > max(idx_main):
raise ValueError("Main factors have to come before combinations.")

# Check that letter combinations are unique
if len(idx_combi) != len({generators[i] for i in idx_combi}):
raise ValueError("Generators are not unique.")

# Check that only letters are used in the combinations that are also single letters (main factors)
if not all(
set(item).issubset({generators[i] for i in idx_main})
for item in [generators[i] for i in idx_combi]
):
raise ValueError("Generators are not valid.")

return generator


def fracfact(gen):
def fracfact(gen) -> np.ndarray:
"""
Create a 2-level fractional-factorial design with a generator string.
Expand Down Expand Up @@ -186,65 +227,59 @@ def fracfact(gen):
>>> fracfact("a b ab")
array([[-1., -1., 1.],
[ 1., -1., -1.],
[-1., 1., -1.],
[ 1., -1., -1.],
[ 1., 1., 1.]])
>>> fracfact("A B AB")
array([[-1., -1., 1.],
[ 1., -1., -1.],
[-1., 1., -1.],
[ 1., -1., -1.],
[ 1., 1., 1.]])
>>> fracfact("a b -ab c +abc")
array([[-1., -1., -1., -1., -1.],
[ 1., -1., 1., -1., 1.],
[-1., 1., 1., -1., 1.],
[ 1., 1., -1., -1., -1.],
[-1., -1., -1., 1., 1.],
[ 1., -1., 1., 1., -1.],
[-1., 1., 1., -1., 1.],
[-1., 1., 1., 1., -1.],
[ 1., -1., 1., -1., 1.],
[ 1., -1., 1., 1., -1.],
[ 1., 1., -1., -1., -1.],
[ 1., 1., -1., 1., 1.]])
"""
# Recognize letters and combinations
A = [item for item in re.split(r"\-|\s|\+", gen) if item] # remove empty strings
C = [len(item) for item in A]
gen = validate_generator(n_factors=gen.count(" ") + 1, generator=gen.lower())

# Indices of single letters (main factors)
I = [i for i, item in enumerate(C) if item == 1] # noqa
generators = [item for item in re.split(r"\-|\s|\+", gen) if item]
lengthes = [len(i) for i in generators]

# Indices of letter combinations (we need them to fill out H2 properly).
J = [i for i, item in enumerate(C) if item != 1]
# Indices of single letters (main factors)
idx_main = [i for i, item in enumerate(lengthes) if item == 1]

# Check if there are "-" or "+" operators in gen
U = [item for item in gen.split(" ") if item] # remove empty strings
# Indices of letter combinations.
idx_combi = [i for i, item in enumerate(generators) if item != 1]

# If R1 is either None or not, the result is not changed, since it is a
# multiplication of 1.
# R1 = _grep(U, "+")
R2 = _grep(U, "-")
# Check if there are "-" operators in gen
idx_negative = [
i for i, item in enumerate(gen.split(" ")) if item[0] == "-"
] # remove empty strings

# Fill in design with two level factorial design
H1 = ff2n(len(I))
H = np.zeros((H1.shape[0], len(C)))
H[:, I] = H1
H1 = ff2n(len(idx_main))
H = np.zeros((H1.shape[0], len(lengthes)))
H[:, idx_main] = H1

# Recognize combinations and fill in the rest of matrix H2 with the proper
# products
for k in J:
for k in idx_combi:
# For lowercase letters
xx = np.array([ord(c) for c in A[k]]) - 97

# For uppercase letters
if np.any(xx < 0):
xx = np.array([ord(c) for c in A[k]]) - 65
xx = np.array([ord(c) for c in generators[k]]) - 97

H[:, k] = np.prod(H1[:, xx], axis=1)

# Update design if gen includes "-" operator
if R2:
H[:, R2] *= -1
if len(idx_negative) > 0:
H[:, idx_negative] *= -1

# Return the fractional factorial design
return H
Expand Down Expand Up @@ -304,12 +339,12 @@ def fracfact_by_res(n, res):
::
>>> fracfact_by_res(6, 3)
array([[-1., -1., -1., 1., 1., 1.],
[ 1., -1., -1., -1., -1., 1.],
[-1., 1., -1., -1., 1., -1.],
[ 1., 1., -1., 1., -1., -1.],
[-1., -1., 1., 1., -1., -1.],
[ 1., -1., 1., -1., 1., -1.],
[-1., 1., -1., -1., 1., -1.],
[-1., 1., 1., -1., -1., 1.],
[ 1., -1., -1., -1., -1., 1.],
[ 1., -1., 1., -1., 1., -1.],
[ 1., 1., -1., 1., -1., -1.],
[ 1., 1., 1., 1., 1., 1.]])
>>> fracfact_by_res(5, 5)
Expand Down
6 changes: 3 additions & 3 deletions tests/test_box_behnken.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@ class TestBoxBehnken(unittest.TestCase):
def test_box_behnken1(self):
expected = [
[-1.0, -1.0, 0.0],
[1.0, -1.0, 0.0],
[-1.0, 1.0, 0.0],
[1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, 0.0, -1.0],
[1.0, 0.0, -1.0],
[-1.0, 0.0, 1.0],
[1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
[0.0, -1.0, -1.0],
[0.0, 1.0, -1.0],
[0.0, -1.0, 1.0],
[0.0, 1.0, -1.0],
[0.0, 1.0, 1.0],
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
Expand Down
8 changes: 4 additions & 4 deletions tests/test_composite.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ class TestComposite(unittest.TestCase):
def test_composite1(self):
expected = [
[-1.0, -1.0, -1.0],
[1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, 1.0, -1.0],
[-1.0, -1.0, 1.0],
[1.0, -1.0, 1.0],
[-1.0, 1.0, -1.0],
[-1.0, 1.0, 1.0],
[1.0, -1.0, -1.0],
[1.0, -1.0, 1.0],
[1.0, 1.0, -1.0],
[1.0, 1.0, 1.0],
[0.0, 0.0, 0.0],
[0.0, 0.0, 0.0],
Expand Down
50 changes: 35 additions & 15 deletions tests/test_factorial.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import unittest
import numpy as np
import pytest

from pyDOE3.doe_factorial import fracfact_opt, fullfact
from pyDOE3.doe_factorial import ff2n
from pyDOE3.doe_factorial import fracfact
from pyDOE3.doe_factorial import fracfact_by_res
import numpy as np
from pyDOE3.doe_factorial import validate_generator


class TestFactorial(unittest.TestCase):
Expand Down Expand Up @@ -40,12 +43,12 @@ def test_factorial1(self):
def test_factorial2(self):
expected = [
[-1.0, -1.0, -1.0],
[1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, 1.0, -1.0],
[-1.0, -1.0, 1.0],
[1.0, -1.0, 1.0],
[-1.0, 1.0, -1.0],
[-1.0, 1.0, 1.0],
[1.0, -1.0, -1.0],
[1.0, -1.0, 1.0],
[1.0, 1.0, -1.0],
[1.0, 1.0, 1.0],
]
actual = ff2n(3)
Expand All @@ -54,8 +57,8 @@ def test_factorial2(self):
def test_factorial3(self):
expected = [
[-1.0, -1.0, 1.0],
[1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, -1.0, -1.0],
[1.0, 1.0, 1.0],
]
actual = fracfact("a b ab")
Expand All @@ -64,8 +67,8 @@ def test_factorial3(self):
def test_factorial4(self):
expected = [
[-1.0, -1.0, 1.0],
[1.0, -1.0, -1.0],
[-1.0, 1.0, -1.0],
[1.0, -1.0, -1.0],
[1.0, 1.0, 1.0],
]
actual = fracfact("A B AB")
Expand All @@ -74,12 +77,12 @@ def test_factorial4(self):
def test_factorial5(self):
expected = [
[-1.0, -1.0, -1.0, -1.0, -1.0],
[1.0, -1.0, 1.0, -1.0, 1.0],
[-1.0, 1.0, 1.0, -1.0, 1.0],
[1.0, 1.0, -1.0, -1.0, -1.0],
[-1.0, -1.0, -1.0, 1.0, 1.0],
[1.0, -1.0, 1.0, 1.0, -1.0],
[-1.0, 1.0, 1.0, -1.0, 1.0],
[-1.0, 1.0, 1.0, 1.0, -1.0],
[1.0, -1.0, 1.0, -1.0, 1.0],
[1.0, -1.0, 1.0, 1.0, -1.0],
[1.0, 1.0, -1.0, -1.0, -1.0],
[1.0, 1.0, -1.0, 1.0, 1.0],
]
actual = fracfact("a b -ab c +abc")
Expand All @@ -88,15 +91,16 @@ def test_factorial5(self):
def test_factorial6(self):
expected = [
[-1.0, -1.0, -1.0, 1.0, 1.0, 1.0],
[1.0, -1.0, -1.0, -1.0, -1.0, 1.0],
[-1.0, 1.0, -1.0, -1.0, 1.0, -1.0],
[1.0, 1.0, -1.0, 1.0, -1.0, -1.0],
[-1.0, -1.0, 1.0, 1.0, -1.0, -1.0],
[1.0, -1.0, 1.0, -1.0, 1.0, -1.0],
[-1.0, 1.0, -1.0, -1.0, 1.0, -1.0],
[-1.0, 1.0, 1.0, -1.0, -1.0, 1.0],
[1.0, -1.0, -1.0, -1.0, -1.0, 1.0],
[1.0, -1.0, 1.0, -1.0, 1.0, -1.0],
[1.0, 1.0, -1.0, 1.0, -1.0, -1.0],
[1.0, 1.0, 1.0, 1.0, 1.0, 1.0],
]
actual = fracfact_by_res(6, 3)
print(actual)
np.testing.assert_allclose(actual, expected)

def test_issue_9(self):
Expand All @@ -118,3 +122,19 @@ def test_issue_9(self):
np.testing.assert_array_equal(
ffo_doe[2], np.array([0.0, 0.0, 3.0, 4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
)


@pytest.mark.parametrize(
"n_factors, generator, message",
[
(2, "a b c", "Generator does not match the number of factors."),
(2, "a a", "Main factors are confounded with each other."),
(2, "a c", "Use the letters `a b` for the main factors."),
(5, "a b c ab ab", "Generators are not unique."),
(5, "a b c ab ad", "Generators are not valid."),
(2, "ab ac", "At least one unconfounded main factor is needed."),
],
)
def test_validate_generator_invalid(n_factors: int, generator: str, message: str):
with pytest.raises(ValueError, match=message):
validate_generator(n_factors, generator)

0 comments on commit 5302b5d

Please sign in to comment.