From f2b364764b01c250ce9bcc7dcf4b71306d08da26 Mon Sep 17 00:00:00 2001 From: Max Halford Date: Tue, 30 Jun 2026 22:17:39 +0200 Subject: [PATCH] Fix MFA column-output labels for categorical/MultiIndex groups (#242) Column outputs now use a clean 2-level (group, variable) MultiIndex, consistent with the partial outputs. Categorical indicator labels read (group, "var__category") instead of the stringified "('group', 'var')__category" prefix that pandas.get_dummies produced from tuple column labels. The X-taking MFA.column_coordinates method previously raised AttributeError (it delegated to a non-existent PCA.column_coordinates); it now raises an explicit NotImplementedError. A FactoMineR-matching implementation needs category barycenters (quali.var$coord) and is left for a follow-up. Bumps version to 0.20.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 7 +++++ prince/mfa.py | 73 ++++++++++++++++++++++++++++++++++++++--------- pyproject.toml | 2 +- tests/test_mfa.py | 63 ++++++++++++++++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 132 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f843fd..804501a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.20.1 — 2026-06-30 + +### Bug fixes + +- **MFA: messy column-output labels with categorical groups and MultiIndex columns**. The index of `column_coordinates_` (and the derived `column_correlations`, `column_contributions_`, `column_cosine_similarities_`) is now a clean 2-level `(group, variable)` `MultiIndex`, consistent with the partial outputs. Categorical indicator labels read `("treatment", "arm__control")` instead of the stringified `"('treatment', 'arm')__control"` tuple prefix that `pandas.get_dummies` produced. Fixes [#242](https://github.com/MaxHalford/prince/issues/242). +- **MFA: `column_coordinates(X)` raised `AttributeError`**. The method delegated to a non-existent `PCA.column_coordinates`. It now raises an explicit `NotImplementedError`. It will be implemented for the next major release. + ## 0.20.0 — 2026-06-29 ### Bug fixes diff --git a/prince/mfa.py b/prince/mfa.py index fad6b4cf..8b92d2f2 100644 --- a/prince/mfa.py +++ b/prince/mfa.py @@ -4,7 +4,7 @@ import collections import enum -from typing import Any, cast +from typing import Any import altair as alt import numpy as np @@ -27,8 +27,7 @@ class GroupType(enum.Enum): # A group's name and the list of (output) column names it maps to. The per-group # preprocessing dict mixes value types (enums, ndarrays, name lists, ints), so it is -# typed loosely as ``dict[str, Any]`` and concrete element types are recovered via casts -# at the use sites below. +# typed loosely as ``dict[str, Any]``. Preprocessing = dict[str, Any] @@ -86,6 +85,7 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): self._group_preprocessing_: dict[Any, Preprocessing] = {} for group, cols in sorted(self.groups_.items()): X_g = X.loc[:, cols] + base_names = [self._strip_group_level(c) for c in cols] if self.group_types_[group] is GroupType.NUMERICAL: fa = pca.PCA( rescale_with_mean=self.rescale_with_mean, @@ -107,7 +107,7 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): "cols": list(cols), "mean": np.asarray(mean, dtype=np.float64), "scale": np.asarray(scale, dtype=np.float64), - "output_names": list(cols), + "output_names": [(group, b) for b in base_names], } else: fa = mca.MCA( @@ -117,8 +117,14 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): random_state=self.random_state, engine=self.engine, ).fit(X_g) + # Build the indicator with within-group feature names as the one-hot prefix, + # so columns read ``var__category`` (matching MCA's convention) rather than a + # stringified ``(group, var)`` tuple prefix when X has MultiIndex columns (#242). indicator = pd.get_dummies( - X_g, columns=list(X_g.columns), prefix_sep="__", dtype=float + X_g.set_axis(base_names, axis="columns"), + columns=base_names, + prefix_sep="__", + dtype=float, ) prop = indicator.mean(axis=0).to_numpy(dtype=np.float64) # FactoMineR's "type='n'" normalization: each indicator column is centered @@ -128,12 +134,13 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): self._group_preprocessing_[group] = { "type": GroupType.CATEGORICAL, "cols": list(cols), + "base_names": base_names, "indicator_columns": list(indicator.columns), "prop": prop, "mean": prop, "scale": scale, "n_vars": len(cols), - "output_names": list(indicator.columns), + "output_names": [(group, c) for c in indicator.columns], } self[group] = fa @@ -142,8 +149,13 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): Z = self._build_Z(X) sup_groups = getattr(self, "supplementary_groups_", []) + # Iterate in ``self.groups_`` order (not user-provided ``sup_groups`` order) so this + # matches the supplementary block order produced by ``_build_Z``. sup_columns = [ - name for g in sup_groups for name in self._group_preprocessing_[g]["output_names"] + name + for g in self.groups_ + if g in sup_groups + for name in self._group_preprocessing_[g]["output_names"] ] # Column weights make each group contribute the same first-eigenvalue inertia (1). @@ -202,6 +214,19 @@ def fit(self, X, y=None, groups=None, supplementary_groups=None): self.rescale_with_mean = prev_rescale_mean self.rescale_with_std = prev_rescale_std + # PCA flattens the MultiIndex columns of Z to a plain list of ``(group, feature)`` + # tuples (``Index.difference(...).tolist()``), so the column outputs come back with a + # flat Index of tuples. Relabel them with a proper 2-level MultiIndex so every column + # output is consistent with the partial outputs (#242). ``feature_names_in_`` keeps the + # active columns in Z order and ``column_coordinates_`` lists active rows then + # supplementary rows, so concatenating the two reproduces the row order exactly. + self._column_index_ = pd.MultiIndex.from_tuples( + [tuple(c) for c in self.feature_names_in_] + [tuple(c) for c in sup_columns], + names=["group", "variable"], + ) + self.column_coordinates_.index = self._column_index_ + self._column_dist.index = self._column_index_ + # Precompute integer column positions for fast slicing in transform methods. # Z is built group-by-group in self.groups_ order, so this matches Z's layout. all_z_cols = [ @@ -245,6 +270,18 @@ def _determine_groups( } return groups + @staticmethod + def _strip_group_level(col): + """Return a column label's within-group portion (the group level removed). + + For MultiIndex columns ``(group, var)`` this is ``var``; for flat columns it is + the label itself. Higher-arity MultiIndex labels keep the trailing levels as a tuple. + """ + if isinstance(col, tuple): + rest = col[1:] + return rest[0] if len(rest) == 1 else rest + return col + def _build_Z(self, X): """Build the global pre-scaled Z block by applying each group's preprocessing. @@ -265,13 +302,23 @@ def _scale_group(self, X, group): if preprocessing["type"] is GroupType.NUMERICAL: arr = (X_g.to_numpy(dtype=np.float64) - preprocessing["mean"]) / preprocessing["scale"] else: + base_names = preprocessing["base_names"] indicator = pd.get_dummies( - X_g.astype(str), columns=list(X_g.columns), prefix_sep="__", dtype=float + X_g.astype(str).set_axis(base_names, axis="columns"), + columns=base_names, + prefix_sep="__", + dtype=float, ).reindex(columns=preprocessing["indicator_columns"], fill_value=0.0) arr = (indicator.to_numpy(dtype=np.float64) - preprocessing["mean"]) / preprocessing[ "scale" ] - return pd.DataFrame(arr, index=X.index, columns=preprocessing["output_names"]) + return pd.DataFrame( + arr, + index=X.index, + columns=pd.MultiIndex.from_tuples( + preprocessing["output_names"], names=["group", "variable"] + ), + ) def _extract_Z_numpy(self, X): """Extract the pre-scaled Z as a numpy array, in active-then-supplementary order.""" @@ -328,10 +375,10 @@ def partial_row_coordinates(self, X): @utils.check_is_dataframe_input @utils.check_is_fitted def column_coordinates(self, X): - Z = self._build_Z(X) - # PCA does not define ``column_coordinates`` in its own MRO, but the concrete - # group factor analyses do; cast to ``Any`` so the dynamic dispatch type-checks. - return cast(Any, super()).column_coordinates(Z) + raise NotImplementedError( + "MFA.column_coordinates is not implemented yet; use the column_coordinates_ " + "attribute for the global-PCA column loadings." + ) @override @utils.check_is_dataframe_input diff --git a/pyproject.toml b/pyproject.toml index 83ca77fa..6a6e2191 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ dependencies = [ "typing-extensions>=4.6", ] name = "prince" -version = "0.20.0" +version = "0.20.1" description = "Factor analysis in Python: PCA, CA, MCA, MFA, FAMD, GPA, PGA" [project.optional-dependencies] diff --git a/tests/test_mfa.py b/tests/test_mfa.py index 2afe103c..809db4ea 100644 --- a/tests/test_mfa.py +++ b/tests/test_mfa.py @@ -217,6 +217,37 @@ def test_mfa_non_numeric_supports_categorical(): sklearn.utils.validation.check_is_fitted(mfa) +def test_column_coordinates_labels_issue_242(): + """Reproduce issue #242: clean (group, variable) MultiIndex labels for a mixed MFA.""" + rng = np.random.RandomState(42) + n = 20 + X = pd.DataFrame( + { + ("chemical", "shared"): rng.randn(n), + ("chemical", "unique"): rng.randn(n), + ("physical", "shared"): rng.randn(n), + ("physical", "unique"): rng.randn(n), + ("treatment", "arm"): rng.choice(["control", "treated"], n), + ("treatment", "site"): rng.choice(["gut", "skin"], n), + } + ) + X.columns = pd.MultiIndex.from_tuples(X.columns) + + index = prince.MFA(n_components=2).fit(X).column_coordinates_.index + + assert isinstance(index, pd.MultiIndex) + assert index.names == ["group", "variable"] + # Numerical groups keep their (group, variable) labels. + assert ("chemical", "shared") in index + assert ("physical", "unique") in index + # Categorical indicators read (group, "variable__category"), not the stringified + # "('treatment', 'arm')__control" the bug produced. + assert ("treatment", "arm__control") in index + assert ("treatment", "arm__treated") in index + assert ("treatment", "site__gut") in index + assert ("treatment", "site__skin") in index + + @pytest.mark.parametrize( "sup_rows, sup_groups", [ @@ -366,6 +397,38 @@ def test_quanti_var_coords(self): P = self.mfa.column_coordinates_.loc[num_cols] np.testing.assert_allclose(F.abs().values, P.abs().values, atol=1e-4) + def test_column_coordinates_index(self): + """Column outputs use a clean 2-level (group, variable) MultiIndex (issue #242). + + Categorical indicator labels read ``variable__category`` rather than a stringified + ``(group, variable)`` tuple, and numerical labels keep their ``(group, variable)`` + form. This holds for the supplementary group too, whose columns appear as + supplementary rows in ``column_coordinates_``. + """ + index = self.mfa.column_coordinates_.index + assert isinstance(index, pd.MultiIndex) + assert index.names == ["group", "variable"] + # The group level spans every group (active groups plus any supplementary one). + assert set(index.get_level_values("group")) == set(self.group_names) + # No label should be a stringified tuple such as "('illness', 'Sick')__Sick_n". + assert not any(str(var).startswith("(") for var in index.get_level_values("variable")) + # Numerical labels stay (group, variable); categorical ones are variable__category. + assert ("description", "Age") in index + assert ("illness", "Sick__Sick_n") in index + assert ("illness", "Sick__Sick_y") in index + # The derived column outputs share the same index. + for derived in ( + self.mfa.column_correlations, + self.mfa.column_cosine_similarities_, + ): + assert isinstance(derived.index, pd.MultiIndex) + assert isinstance(self.mfa.column_contributions_.index, pd.MultiIndex) + + def test_column_coordinates_method_not_implemented(self): + """The X-taking column_coordinates method is not implemented yet (see #242).""" + with pytest.raises(NotImplementedError): + self.mfa.column_coordinates(self.dataset) + def test_partial_axis_correlations(self): """Partial-axis correlations should match FactoMineR for the active groups. diff --git a/uv.lock b/uv.lock index 0ab97348..78f7412d 100644 --- a/uv.lock +++ b/uv.lock @@ -1535,7 +1535,7 @@ wheels = [ [[package]] name = "prince" -version = "0.20.0" +version = "0.20.1" source = { editable = "." } dependencies = [ { name = "altair" },