Repository: matthewwardrop/formulaic

Issue:
Exposing contrast matrices for external consumption
`statsmodels` directly consumes the contrast matrices used during the materialization of design matrices in `patsy` in order to do pairwise t-tests. Since formulaic doesn't store these matrices, and doesn't provide a way to generate them, this makes doing these pairwise tests cumbersome when using formulaic.

We should add support for constructing the model matrices from model spec metadata, even if we don't actually hard-code the matrices themselves into the model spec.

ref: https://github.com/statsmodels/statsmodels/issues/6858#issuecomment-2477181037

Hints from issue comments:

Solution patch:
diff --git a/docsite/docs/guides/model_specs.ipynb b/docsite/docs/guides/model_specs.ipynb
index eab6a287..69ecb66f 100644
--- a/docsite/docs/guides/model_specs.ipynb
+++ b/docsite/docs/guides/model_specs.ipynb
@@ -87,14 +87,24 @@
     "    * **term_slices**: An ordered mapping of `Term` instances to a slice that \n",
     "        when used on the columns of the model matrix will subsample the model\n",
     "        matrix down to those corresponding to each term.\n",
+    "    * **term_factors**: An ordered mapping of `Term` instances to the set of \n",
+    "        factors used by that term.\n",
     "    * **term_variables**: An order mapping of `Term` instances to `Variable`\n",
     "        instances (a string subclass with addition attributes of `roles` and \n",
     "        `source`), indicating the variables used by that term.\n",
-    "    * **variable_terms**: The reverse lookup of the above.\n",
-    "    * **variable_indices**: A mapping from `Variable` instance to the indices\n",
-    "        of the columns in the model matrix that variable.\n",
+    "    * **factors**: A set of `Factor` instances used in the entire formula.\n",
+    "    * **factor_terms**: A mapping from `Factor` instances to the `Term` \n",
+    "        instances that used them.\n",
+    "    * **factor_variables**: A mapping from `Factor` instances to `Variable` \n",
+    "        instances, corresponding to the variables used by that factor.\n",
+    "    * **factor_contrasts**: A mapping from `Factor` instances to\n",
+    "        `ContrastsState` instances that can be used to reproduce the coding\n",
+    "        matrices used during materialization.\n",
     "    * **variables**: A set of `Variable` instances describing the variables\n",
     "        used in entire formula.\n",
+    "    * **variable_terms**: The reverse lookup of `term_variables`.\n",
+    "    * **variable_indices**: A mapping from `Variable` instance to the indices\n",
+    "        of the columns in the model matrix that variable.\n",
     "    * **variables_by_source**: A mapping from source name (typically one of \n",
     "        `\"data\"`, `\"context\"`, or `\"transforms\"`) to the variables derived from\n",
     "        that source.\n",
@@ -215,7 +225,7 @@
     {
      "data": {
       "text/plain": [
-       "ModelSpec(formula=1 + center(a) + b, materializer='pandas', materializer_params={}, ensure_full_rank=True, na_action=<NAAction.DROP: 'drop'>, output='pandas', cluster_by=<ClusterBy.NONE: 'none'>, structure=[EncodedTermStructure(term=1, scoped_terms=[1], columns=['Intercept']), EncodedTermStructure(term=center(a), scoped_terms=[center(a)], columns=['center(a)']), EncodedTermStructure(term=b, scoped_terms=[b-], columns=['b[T.B]', 'b[T.C]'])], transform_state={'center(a)': {'ddof': 1, 'center': 2.0, 'scale': None}}, encoder_state={'center(a)': (<Kind.NUMERICAL: 'numerical'>, {}), 'b': (<Kind.CATEGORICAL: 'categorical'>, {'categories': ['A', 'B', 'C']})})"
+       "ModelSpec(formula=1 + center(a) + b, materializer='pandas', materializer_params={}, ensure_full_rank=True, na_action=<NAAction.DROP: 'drop'>, output='pandas', cluster_by=<ClusterBy.NONE: 'none'>, structure=[EncodedTermStructure(term=1, scoped_terms=[1], columns=['Intercept']), EncodedTermStructure(term=center(a), scoped_terms=[center(a)], columns=['center(a)']), EncodedTermStructure(term=b, scoped_terms=[b-], columns=['b[T.B]', 'b[T.C]'])], transform_state={'center(a)': {'ddof': 1, 'center': np.float64(2.0), 'scale': None}}, encoder_state={'center(a)': (<Kind.NUMERICAL: 'numerical'>, {}), 'b': (<Kind.CATEGORICAL: 'categorical'>, {'categories': ['A', 'B', 'C'], 'contrasts': ContrastsState(contrasts=TreatmentContrasts(base=UNSET), levels=['A', 'B', 'C'])})})"
       ]
      },
      "execution_count": 2,
@@ -244,11 +254,16 @@
        " 'term_slices': {1: slice(0, 1, None),\n",
        "  center(a): slice(1, 2, None),\n",
        "  b: slice(2, 4, None)},\n",
+       " 'term_factors': {1: {1}, center(a): {center(a)}, b: {b}},\n",
        " 'term_variables': {1: set(), center(a): {'a', 'center'}, b: {'b'}},\n",
+       " 'factors': {1, b, center(a)},\n",
+       " 'factor_terms': {1: {1}, center(a): {center(a)}, b: {b}},\n",
+       " 'factor_variables': {b: {'b'}, 1: set(), center(a): {'a', 'center'}},\n",
+       " 'factor_contrasts': {b: ContrastsState(contrasts=TreatmentContrasts(base=UNSET), levels=['A', 'B', 'C'])},\n",
+       " 'variables': {'a', 'b', 'center'},\n",
        " 'variable_terms': {'center': {center(a)}, 'a': {center(a)}, 'b': {b}},\n",
        " 'variable_indices': {'center': [1], 'a': [1], 'b': [2, 3]},\n",
-       " 'variables': {'a', 'b', 'center'},\n",
-       " 'variables_by_source': {'data': {'a', 'b'}, 'transforms': {'center'}}}"
+       " 'variables_by_source': {'transforms': {'center'}, 'data': {'a', 'b'}}}"
       ]
      },
      "execution_count": 3,
@@ -257,17 +272,22 @@
     }
    ],
    "source": [
-    "# We can now interrogate it for various column and term related metadata\n",
+    "# We can now interrogate it for various column, factor, term, and variable related metadata\n",
     "{\n",
     "    \"column_names\": ms.column_names,\n",
     "    \"column_indices\": ms.column_indices,\n",
     "    \"terms\": ms.terms,\n",
     "    \"term_indices\": ms.term_indices,\n",
     "    \"term_slices\": ms.term_slices,\n",
+    "    \"term_factors\": ms.term_factors,\n",
     "    \"term_variables\": ms.term_variables,\n",
+    "    \"factors\": ms.factors,\n",
+    "    \"factor_terms\": ms.factor_terms,\n",
+    "    \"factor_variables\": ms.factor_variables,\n",
+    "    \"factor_contrasts\": ms.factor_contrasts,\n",
+    "    \"variables\": ms.variables,\n",
     "    \"variable_terms\": ms.variable_terms,\n",
     "    \"variable_indices\": ms.variable_indices,\n",
-    "    \"variables\": ms.variables,\n",
     "    \"variables_by_source\": ms.variables_by_source,\n",
     "}"
    ]
@@ -440,7 +460,7 @@
      "name": "stderr",
      "output_type": "stream",
      "text": [
-      "/home/matthew/Repositories/github/formulaic/formulaic/transforms/contrasts.py:155: DataMismatchWarning: Data has categories outside of the nominated levels (or that were not seen in original dataset): {'D'}. They are being  cast to nan, which will likely skew the results of your analyses.\n",
+      "/home/matthew/Repositories/github/formulaic/formulaic/transforms/contrasts.py:169: DataMismatchWarning: Data has categories outside of the nominated levels (or that were not seen in original dataset): {'D'}. They are being  cast to nan, which will likely skew the results of your analyses.\n",
       "  warnings.warn(\n"
      ]
     },
@@ -786,7 +806,7 @@
  ],
  "metadata": {
   "kernelspec": {
-   "display_name": "Python 3",
+   "display_name": "venv",
    "language": "python",
    "name": "python3"
   },
@@ -800,14 +820,9 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.11.2"
+   "version": "3.12.5"
   },
-  "orig_nbformat": 4,
-  "vscode": {
-   "interpreter": {
-    "hash": "47dead3f41f007c145e1b6a3a3236d0073529fa0f84becc646efdc563d7d1d7c"
-   }
-  }
+  "orig_nbformat": 4
  },
  "nbformat": 4,
  "nbformat_minor": 2
diff --git a/formulaic/model_spec.py b/formulaic/model_spec.py
index 00a3ffef..d1c7e2a4 100644
--- a/formulaic/model_spec.py
+++ b/formulaic/model_spec.py
@@ -16,7 +16,7 @@
 )
 
 from formulaic.materializers.base import EncodedTermStructure
-from formulaic.parser.types import Structured, Term
+from formulaic.parser.types import Factor, Structured, Term
 from formulaic.utils.constraints import LinearConstraints, LinearConstraintSpec
 from formulaic.utils.variables import Variable
 
@@ -24,6 +24,7 @@
 from .materializers import ClusterBy, FormulaMaterializer, NAAction
 
 if TYPE_CHECKING:  # pragma: no cover
+    from .transforms.contrasts import ContrastsState
     from .model_matrix import ModelMatrices, ModelMatrix
 
 # Cached property was introduced in Python 3.8 (we currently support 3.7)
@@ -209,6 +210,18 @@ def term_slices(self) -> Dict[Term, slice]:
             for k, v in self.term_indices.items()
         }
 
+    @cached_property
+    def term_factors(self) -> Dict[Term, Set[Factor]]:
+        """
+        A mapping from `Term` instances to the factors which were used to
+        generate them.
+        """
+        term_factors: Dict[Term, Set[Factor]] = defaultdict(set)
+        for term in self.terms:
+            for factor in term.factors:
+                term_factors[term].add(factor)
+        return dict(term_factors)
+
     @cached_property
     def term_variables(self) -> Dict[Term, Set[Variable]]:
         """
@@ -224,15 +237,98 @@ def term_variables(self) -> Dict[Term, Set[Variable]]:
                 "to the model matrix generated when calling `.get_model_matrix()`."
             )
         term_variables = {}
-        start = 0
         for row in self.structure:
-            end = start + len(row[2])
             term_variables[row[0]] = Variable.union(
                 *(term.variables for term in row[1]),
             )
-            start = end
         return term_variables
 
+    @cached_property
+    def factors(self) -> Set[Factor]:
+        """
+        The factors used to generate model matrices from this `ModelSpec`
+        instance.
+        """
+        return {factor for term in self.terms for factor in term.factors}
+
+    @cached_property
+    def factor_terms(self) -> Dict[Factor, Set[Term]]:
+        """
+        A mapping from `Factor` instances to the terms which used it. This is
+        the reverse mapping of `.term_factors`.
+        """
+        factor_terms: Dict[Factor, Set[Term]] = defaultdict(set)
+        for term, factors in self.term_factors.items():
+            for factor in factors:
+                factor_terms[factor].add(term)
+        return dict(factor_terms)
+
+    @cached_property
+    def factor_variables(self) -> Dict[Factor, Set[Variable]]:
+        """
+        A mapping from `Factor` instances to the variables used in the evaluation
+        of that factor.
+        """
+
+        if self.structure is None:
+            raise RuntimeError(
+                "`ModelSpec.structure` has not yet been populated. This will "
+                "likely be resolved by using the `ModelSpec` instance attached "
+                "to the model matrix generated when calling `.get_model_matrix()`."
+            )
+
+        factor_variables: Dict[Factor, List[Variable]] = defaultdict(list)
+        for s in self.structure:
+            for scoped_term in s.scoped_terms:
+                for scoped_factor in scoped_term.factors:
+                    factor_variables[scoped_factor.factor.factor].extend(
+                        scoped_factor.factor.variables
+                    )
+
+        return {
+            factor: Variable.union(factor_variables.get(factor, []))
+            for factor in self.factors
+        }
+
+    @cached_property
+    def factor_contrasts(self) -> Dict[Factor, ContrastsState]:
+        """
+        A mapping of `Factor` instances to their contrasts state. This is useful
+        if you would like to introspect some of the coding choices, or reuse
+        these encodings outside of formulaic. Only categorical factors that were
+        encoded by Formulaic will be included in this mapping.
+
+        Note that these contrast states do *not* include whether the factor was
+        encoded using reduced rank or not, since this is potentially ambiguous
+        even within a single term. [Depending on the context, a factor may
+        be encoded as either or both full and reduced rank in order to fully
+        span the vector space]. Instead, you can choose whether to reduce the
+        rank using:
+        ```
+        model_spec.factor_contrasts[<factor>].get_coding_matrix(reduced_rank=True)
+        ```
+        If not specified, the default is to reduce the rank, which gives the
+        more interesting matrices.
+
+        Refer to the documentation of `ContrastsState` for more details.
+        """
+        return {
+            factor: self.encoder_state[factor][1]["contrasts"]
+            for factor in self.factors
+            if factor in self.encoder_state
+            and self.encoder_state[factor][0] is Factor.Kind.CATEGORICAL
+            and "contrasts" in self.encoder_state[factor][1]
+        }
+
+    @cached_property
+    def variables(self) -> Set[Variable]:
+        """
+        The variables used during the materialization of the entire formula.
+        """
+        return Variable.union(
+            *(variables for variables in self.term_variables.values())
+        )
+
     @cached_property
     def variable_terms(self) -> Dict[Variable, Set[Term]]:
         """
@@ -258,12 +354,6 @@ def variable_indices(self) -> Dict[Variable, List[int]]:
             for variable, terms in self.variable_terms.items()
         }
 
-    @cached_property
-    def variables(self) -> Set[Variable]:
-        return Variable.union(
-            *(variables for variables in self.term_variables.values())
-        )
-
     @cached_property
     def variables_by_source(self) -> Dict[Optional[str], Set[Variable]]:
         """
diff --git a/formulaic/transforms/contrasts.py b/formulaic/transforms/contrasts.py
index 54e52156..62bf2622 100644
--- a/formulaic/transforms/contrasts.py
+++ b/formulaic/transforms/contrasts.py
@@ -3,6 +3,7 @@
 import inspect
 import warnings
 from abc import abstractmethod
+from dataclasses import dataclass
 from numbers import Number
 from typing import (
     TYPE_CHECKING,
@@ -35,6 +36,19 @@
     from formulaic.model_spec import ModelSpec  # pragma: no cover
 
 
+class _UnsetSentinel:
+    def __new__(cls, *args: Any, **kwargs: Any) -> _UnsetSentinel:
+        if not hasattr(cls, "_instance"):
+            cls._instance = super().__new__(cls)
+        return cls._instance
+
+    def __repr__(self) -> str:
+        return "UNSET"
+
+
+UNSET = _UnsetSentinel()
+
+
 def C(
     data: Any,
     contrasts: Optional[
@@ -175,6 +189,10 @@ def encode_contrasts(  # pylint: disable=dangerous-default-value  # always repla
 
     # Update state
     _state["categories"] = categories
+    # Add `contrasts` to state for introspection purposes only. It is not used
+    # as the source of truth because it is easier for users to simply provide
+    # the "categories" state if manually specifying encoder state.
+    _state["contrasts"] = ContrastsState(contrasts, categories)
 
     # Apply and return contrasts
     return contrasts.apply(
@@ -465,6 +483,7 @@ def get_factor_format(
         return self.FACTOR_FORMAT
 
 
+@dataclass
 class TreatmentContrasts(Contrasts):
     """
     Treatment (aka. dummy) coding.
@@ -476,10 +495,7 @@ class TreatmentContrasts(Contrasts):
 
     FACTOR_FORMAT = "{name}[T.{field}]"
 
-    MISSING = object()
-
-    def __init__(self, base: Hashable = MISSING):
-        self.base = base
+    base: Hashable = UNSET
 
     @Contrasts.override
     def _apply(
@@ -501,7 +517,7 @@ def _apply(
         return dummies
 
     def _find_base_index(self, levels: Sequence[Hashable]) -> int:
-        if self.base is self.MISSING:
+        if self.base is UNSET:
             return 0
         try:
             return levels.index(self.base)
@@ -551,9 +567,10 @@ def get_drop_field(
     ) -> Hashable:
         if reduced_rank:
             return None
-        return self.base if self.base is not self.MISSING else levels[0]
+        return self.base if self.base is not UNSET else levels[0]
 
 
+@dataclass
 class SASContrasts(TreatmentContrasts):
     """
     SAS (treatment) contrast coding.
@@ -565,7 +582,7 @@ class SASContrasts(TreatmentContrasts):
 
     @TreatmentContrasts.override  # type: ignore[attr-defined]
     def _find_base_index(self, levels: Sequence[Hashable]) -> int:
-        if self.base is self.MISSING:
+        if self.base is UNSET:
             return len(levels) - 1
         try:
             return levels.index(self.base)
@@ -580,9 +597,10 @@ def get_drop_field(
     ) -> Hashable:
         if reduced_rank:
             return None
-        return self.base if self.base is not self.MISSING else levels[-1]
+        return self.base if self.base is not UNSET else levels[-1]
 
 
+@dataclass
 class SumContrasts(Contrasts):
     """
     Sum (or Deviation) coding.
@@ -624,6 +642,7 @@ def get_coefficient_row_names(
         return levels
 
 
+@dataclass
 class HelmertContrasts(Contrasts):
     """
     Helmert coding.
@@ -642,9 +661,8 @@ class HelmertContrasts(Contrasts):
 
     FACTOR_FORMAT = "{name}[H.{field}]"
 
-    def __init__(self, *, reverse: bool = True, scale: bool = False):
-        self.reverse = reverse
-        self.scale = scale
+    reverse: bool = True
+    scale: bool = False
 
     @Contrasts.override
     def _get_coding_matrix(
@@ -696,6 +714,7 @@ def get_coefficient_row_names(
         return levels
 
 
+@dataclass
 class DiffContrasts(Contrasts):
     """
     Difference coding.
@@ -712,8 +731,7 @@ class DiffContrasts(Contrasts):
 
     FACTOR_FORMAT = "{name}[D.{field}]"
 
-    def __init__(self, backward: bool = True):
-        self.backward = backward
+    backward: bool = True
 
     @Contrasts.override
     def _get_coding_matrix(
@@ -760,6 +778,7 @@ def get_coefficient_row_names(
         return levels
 
 
+@dataclass
 class PolyContrasts(Contrasts):
     """
     (Orthogonal) Polynomial coding.
@@ -780,8 +799,7 @@ class PolyContrasts(Contrasts):
         3: ".C",
     }
 
-    def __init__(self, scores: Optional[Sequence[float]] = None):
-        self.scores = scores
+    scores: Optional[Sequence[float]] = None
 
     @Contrasts.override
     def _get_coding_matrix(
@@ -823,12 +841,16 @@ def get_coefficient_row_names(
         return levels
 
 
+@dataclass(init=False)
 class CustomContrasts(Contrasts):
     """
     Handle the custom contrast case when users pass in hand-coded contrast
     matrices.
     """
 
+    contrasts: numpy.ndarray
+    names: Optional[Sequence[Hashable]] = None
+
     def __init__(
         self,
         contrasts: Union[
@@ -904,3 +926,57 @@ class ContrastsRegistry(type):
     # Extra
     diff = DiffContrasts
     custom = CustomContrasts
+
+
+@dataclass
+class ContrastsState:
+    """
+    Combines a `Contrasts` instance with information collected at runtime in
+    order to allow introspection of the contrast matrices used during model
+    matrix materialization.
+
+    Attributes:
+        contrasts: The `Contrasts` instance used to encode the data.
+        levels: The names of the levels/categories in the data.
+        reduced_rank: Whether the contrast encoding used had reduced rank.
+    """
+
+    contrasts: Contrasts
+    levels: Sequence[Hashable]
+
+    def get_coding_matrix(
+        self, reduced_rank: bool = True, sparse: bool = False
+    ) -> Union[pandas.DataFrame, spsparse.spmatrix]:
+        """
+        Generate the coding matrix used during materialization; i.e. the matrix
+        with column vectors representing the encoding to use for the
+        corresponding level.
+
+        Args:
+            reduced_rank: Whether to output a reduced rank matrix. When this is
+                `False`, the dummy encoding is usually passed through
+                unmodified.
+            sparse: Whether to output sparse results.
+        """
+        return self.contrasts.get_coding_matrix(
+            self.levels, reduced_rank=reduced_rank, sparse=sparse
+        )
+
+    def get_coefficient_matrix(
+        self, reduced_rank: bool = True, sparse: bool = False
+    ) -> Union[pandas.DataFrame, spsparse.spmatrix]:
+        """
+        Generate the coefficient matrix corresponding to the coding matrix used
+        during materialization; i.e. the matrix with rows representing the
+        contrasts effectively computed during a regression, with columns
+        indicating the weights given to the origin categories. This is primarily
+        used for debugging/introspection.
+
+        Args:
+            reduced_rank: Whether to output the coefficients for reduced rank
+                encodings.
+            sparse: Whether to output sparse results.
+        """
+        return self.contrasts.get_coefficient_matrix(
+            self.levels, reduced_rank=reduced_rank, sparse=sparse
+        )
diff --git a/formulaic/transforms/patsy_compat.py b/formulaic/transforms/patsy_compat.py
index a58e593f..0831d77e 100644
--- a/formulaic/transforms/patsy_compat.py
+++ b/formulaic/transforms/patsy_compat.py
@@ -8,6 +8,7 @@
     PolyContrasts,
     SumContrasts,
     TreatmentContrasts,
+    UNSET,
 )
 from .scale import scale
 
@@ -23,7 +24,7 @@ def standardize(
     return scale(x, center=center, scale=rescale, ddof=ddof, _state=_state)
 
 
-def Treatment(reference: Any = TreatmentContrasts.MISSING) -> TreatmentContrasts:
+def Treatment(reference: Any = UNSET) -> TreatmentContrasts:
     return TreatmentContrasts(base=reference)
 
 
diff --git a/formulaic/utils/variables.py b/formulaic/utils/variables.py
index f1f8dac6..e075e400 100644
--- a/formulaic/utils/variables.py
+++ b/formulaic/utils/variables.py
@@ -30,7 +30,7 @@ def __new__(
         return s
 
     @classmethod
-    def union(cls, *variable_sets: Set[Variable]) -> Set[Variable]:
+    def union(cls, *variable_sets: Iterable[Variable]) -> Set[Variable]:
         variables: Dict[Variable, Variable] = {}
         for variable_set in variable_sets:
             for variable in variable_set:


Test patch:
diff --git a/tests/test_model_spec.py b/tests/test_model_spec.py
index abdc9350..d19a6c09 100644
--- a/tests/test_model_spec.py
+++ b/tests/test_model_spec.py
@@ -78,18 +78,33 @@ def test_attributes(self, model_spec):
             "A:a": slice(4, 6),
         }
         assert model_spec.terms == ["1", "a", "A", "A:a"]
+        assert model_spec.term_factors == {
+            "1": {"1"},
+            "a": {"a"},
+            "A": {"A"},
+            "A:a": {"a", "A"},
+        }
         assert model_spec.term_variables == {
             "1": set(),
             "a": {"a"},
             "A": {"A"},
             "A:a": {"a", "A"},
         }
+        assert model_spec.factors == {"1", "a", "A"}
+        assert model_spec.factor_terms == {
+            "1": {"1"},
+            "a": {"a", "A:a"},
+            "A": {"A", "A:a"},
+        }
+        assert model_spec.factor_variables == {"1": set(), "a": {"a"}, "A": {"A"}}
+        assert set(model_spec.factor_contrasts) == {"A"}
+        assert model_spec.factor_contrasts["A"].levels == ["a", "b", "c"]
+        assert model_spec.variables == {"a", "A"}
         assert model_spec.variable_terms == {"a": {"a", "A:a"}, "A": {"A", "A:a"}}
         assert model_spec.variable_indices == {
             "a": [1, 4, 5],
             "A": [2, 3, 4, 5],
         }
-        assert model_spec.variables == {"a", "A"}
         assert model_spec.variables_by_source == {"data": {"a", "A"}}
 
     def test_get_model_matrix(self, model_spec, data2):
@@ -184,6 +199,8 @@ class MyPandasMaterializer(PandasMaterializer):
     def test_empty(self):
         model_spec = ModelSpec([])
 
+        assert model_spec.factor_contrasts == {}
+
         with pytest.raises(
             RuntimeError,
             match=re.escape(
@@ -214,6 +231,16 @@ def test_empty(self):
         ):
             model_spec.term_variables
 
+        with pytest.raises(
+            RuntimeError,
+            match=re.escape(
+                "`ModelSpec.structure` has not yet been populated. This will "
+                "likely be resolved by using the `ModelSpec` instance attached "
+                "to the model matrix generated when calling `.get_model_matrix()`."
+            ),
+        ):
+            model_spec.factor_variables
+
     def test_unrepresented_term(self):
         model_spec = (
             Formula("A")
diff --git a/tests/transforms/test_contrasts.py b/tests/transforms/test_contrasts.py
index d123ca87..53104bcc 100644
--- a/tests/transforms/test_contrasts.py
+++ b/tests/transforms/test_contrasts.py
@@ -11,10 +11,13 @@
 from formulaic.model_spec import ModelSpec
 from formulaic.transforms.contrasts import (
     ContrastsRegistry as contr,
+    ContrastsState,
 )
 from formulaic.transforms.contrasts import (
     SumContrasts,
     encode_contrasts,
+    UNSET,
+    _UnsetSentinel,
 )
 from formulaic.utils.sparse import categorical_encode_series_to_sparse_csc_matrix
 
@@ -31,6 +34,11 @@ def _compare_factor_values(a, b, comp=lambda x, y: numpy.allclose(x, y)):
     assert a.__formulaic_metadata__ == b.__formulaic_metadata__
 
 
+def test_unset_sentinel():
+    assert _UnsetSentinel() is UNSET
+    assert repr(UNSET) == "UNSET"
+
+
 class TestContrastsTransform:
     def test_basic_usage_and_state(self):
         state = {}
@@ -58,6 +66,7 @@ def test_basic_usage_and_state(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
         with pytest.warns(DataMismatchWarning):
             _compare_factor_values(
@@ -83,6 +92,7 @@ def test_basic_usage_and_state(self):
                 ),
             )
             assert state["categories"] == ["a", "b", "c"]
+            assert "contrasts" in state
 
         _compare_factor_values(
             encode_contrasts(
@@ -107,6 +117,7 @@ def test_basic_usage_and_state(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
     def test_sparse(self):
         state = {}
@@ -134,6 +145,7 @@ def test_sparse(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
         _compare_factor_values(
             encode_contrasts(
@@ -158,6 +170,7 @@ def test_sparse(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
         with pytest.warns(DataMismatchWarning):
             _compare_factor_values(
@@ -183,6 +196,7 @@ def test_sparse(self):
                 ),
             )
             assert state["categories"] == ["a", "b", "c"]
+            assert "contrasts" in state
 
     def test_numpy(self):
         assert isinstance(
@@ -217,6 +231,7 @@ def test_specifying_encode_contrasts(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
     def test_specifying_contrast_class(self):
         state = {}
@@ -243,6 +258,7 @@ def test_specifying_contrast_class(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
     def test_specifying_custom_encode_contrasts(self):
         state = {}
@@ -267,6 +283,7 @@ def test_specifying_custom_encode_contrasts(self):
             ),
         )
         assert state["categories"] == ["a", "b", "c"]
+        assert "contrasts" in state
 
     def test_invalid_output_type(self):
         with pytest.raises(ValueError, match=r"^Unknown output type"):
@@ -915,3 +932,14 @@ def test_full_rankness_opt_out():
         "C(A, spans_intercept=False)[T.b]",
         "C(A, spans_intercept=False)[T.c]",
     )
+
+
+def test_contrasts_state():
+    assert numpy.allclose(
+        ContrastsState(contr.helmert(), ["a", "b", "c"]).get_coding_matrix(),
+        contr.helmert().get_coding_matrix(["a", "b", "c"]),
+    )
+    assert numpy.allclose(
+        ContrastsState(contr.helmert(), ["a", "b", "c"]).get_coefficient_matrix(),
+        contr.helmert().get_coefficient_matrix(["a", "b", "c"]),
+    )