Repository: matthewwardrop/formulaic

Issue:
Bug handling categorical features?
I am training a model with a `pd.categorical` feature with categories not following alphabetic order. During prediction I pass this categorical feature as string. When transforming getting the model matrix using `X.model_spec.get_model_matrix(x_string)`, the string feature is transformed to a pd.categorical with features alphabetically ordered resulting in` FactorEncodingError: Term categorical feature has generated columns that are inconsistent with specification`. The number and name of columns is the same but the other is different.

 I use this code to replicate this error:

 ```import pandas as pd
from formulaic import model_matrix
from random import shuffle
import numpy as np

for cat_order in [['A','G','B','D','E','F'], ['A','B','D','E','F']]:
    # Define the model matrix
    examples = cat_order*2
    shuffle(examples)
    df = pd.DataFrame({'y': [i for i, _ in enumerate(examples)], 'a':pd.Categorical(examples, cat_order)})
    y, X = model_matrix("y ~ C(a)", df)
    # Check if we can transform a categorical feature with specified order, a noncateegorical feature 
    # into a model matrix
    from formulaic.errors import FactorEncodingError
    df_test = pd.DataFrame({
        'y': [17,18],
        'a': pd.Categorical(['A','B'], cat_order),

    })
    df_testb = pd.DataFrame({
        'y': [17,18],
        'a': ['A','B'],

    })

    print('categorical order',cat_order)
    for df, ftype in [(df_test,'cat'),(df_testb,'non-cat')]:
        try:
            X.model_spec.get_model_matrix(df)
            print(f'{ftype} passes')
        except FactorEncodingError:
            print(f'{ftype} does not pass')
```

outputs:
```
categorical order ['A', 'G', 'B', 'D', 'E', 'F']
cat passes
non-cat does not pass
categorical order ['A', 'B', 'D', 'E', 'F']
cat passes
non-cat passes
```

Discussion:

Patch:
diff --git a/formulaic/materializers/base.py b/formulaic/materializers/base.py
index b28766d..877ffb1 100644
--- a/formulaic/materializers/base.py
+++ b/formulaic/materializers/base.py
@@ -5,7 +5,7 @@ import itertools
 import operator
 from abc import abstractmethod
 from collections import defaultdict, OrderedDict
-from typing import Any, Dict, Iterable, Set, TYPE_CHECKING
+from typing import Any, Dict, Generator, List, Iterable, Set, Tuple, TYPE_CHECKING
 
 from interface_meta import InterfaceMeta, inherit_docs
 
@@ -20,7 +20,7 @@ from formulaic.model_matrix import ModelMatrix
 from formulaic.utils.layered_mapping import LayeredMapping
 from formulaic.utils.stateful_transforms import stateful_eval
 
-from formulaic.parser.types import Factor
+from formulaic.parser.types import Factor, Term
 from formulaic.transforms import TRANSFORMS
 
 from .types import EvaluatedFactor, FactorValues, ScopedFactor, ScopedTerm
@@ -560,7 +560,12 @@ class FormulaMaterializer(metaclass=FormulaMaterializerMeta):
 
     # Methods related to ModelMatrix output
 
-    def _enforce_structure(self, cols, spec, drop_rows):
+    def _enforce_structure(
+        self,
+        cols: List[Tuple[Term, List[ScopedTerm], Dict[str, Any]]],
+        spec,
+        drop_rows: set,
+    ) -> Generator[Tuple[Term, List[ScopedTerm], Dict[str, Any]]]:
         # TODO: Verify that imputation strategies are intuitive and make sense.
         assert len(cols) == len(spec.structure)
         for i in range(len(cols)):
@@ -580,15 +585,12 @@ class FormulaMaterializer(metaclass=FormulaMaterializerMeta):
                         f"Term `{cols[i][0]}` has generated insufficient columns compared to specification: generated {list(scoped_cols)}, expecting {target_cols}."
                     )
                 scoped_cols = {name: col for name in target_cols}
-            elif (
-                len(scoped_cols) == len(target_cols)
-                and list(scoped_cols) != target_cols
-            ):
+            elif set(scoped_cols) != set(target_cols):
                 raise FactorEncodingError(
                     f"Term `{cols[i][0]}` has generated columns that are inconsistent with specification: generated {list(scoped_cols)}, expecting {target_cols}."
                 )
 
-            yield cols[i][0], cols[i][1], scoped_cols
+            yield cols[i][0], cols[i][1], {col: scoped_cols[col] for col in target_cols}
 
     def _get_columns_for_term(self, factors, spec, scale=1):
         """


Test patch:
diff --git a/tests/materializers/test_pandas.py b/tests/materializers/test_pandas.py
index 59d7093..f290288 100644
--- a/tests/materializers/test_pandas.py
+++ b/tests/materializers/test_pandas.py
@@ -311,3 +311,34 @@ class TestPandasMaterializer:
         mm = PandasMaterializer(data).get_model_matrix("1 + a + A")
         assert list(mm.index) == [0, 2, 4, 8, 10]
         assert not numpy.any(pandas.isnull(mm))
+
+    def test_category_reordering(self):
+        data = pandas.DataFrame({"A": ["a", "b", "c"]})
+        data2 = pandas.DataFrame({"A": ["c", "b", "a"]})
+        data3 = pandas.DataFrame(
+            {"A": pandas.Categorical(["c", "b", "a"], categories=["c", "b", "a"])}
+        )
+
+        m = PandasMaterializer(data).get_model_matrix("A + 0", ensure_full_rank=False)
+        assert list(m.columns) == ["A[T.a]", "A[T.b]", "A[T.c]"]
+        assert list(m.model_spec.get_model_matrix(data3).columns) == [
+            "A[T.a]",
+            "A[T.b]",
+            "A[T.c]",
+        ]
+
+        m2 = PandasMaterializer(data2).get_model_matrix("A + 0", ensure_full_rank=False)
+        assert list(m2.columns) == ["A[T.a]", "A[T.b]", "A[T.c]"]
+        assert list(m2.model_spec.get_model_matrix(data3).columns) == [
+            "A[T.a]",
+            "A[T.b]",
+            "A[T.c]",
+        ]
+
+        m3 = PandasMaterializer(data3).get_model_matrix("A + 0", ensure_full_rank=False)
+        assert list(m3.columns) == ["A[T.c]", "A[T.b]", "A[T.a]"]
+        assert list(m3.model_spec.get_model_matrix(data).columns) == [
+            "A[T.c]",
+            "A[T.b]",
+            "A[T.a]",
+        ]