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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
73 changes: 60 additions & 13 deletions prince/mfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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]


Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand All @@ -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
Expand All @@ -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

Expand All @@ -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).
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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.

Expand All @@ -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."""
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
63 changes: 63 additions & 0 deletions tests/test_mfa.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.