"""(c) Kevin Dunn, 2010-2026. MIT License.
Mixed Assessor Model (MAM): per-assessor scaling and scale alignment.
The classical assessor-by-product interaction lumps together two different
things: a panelist who simply uses a wider or narrower part of the scale (a
multiplicative scaling difference), and a panelist who genuinely ranks the
products differently (real disagreement). The MAM separates them.
For each attribute, regress every panelist's product means on the panel
consensus product means. The slope is the panelist's scaling coefficient
``beta``:
* ``beta`` near 1: uses the scale like the panel;
* ``beta`` < 1: compresses (narrow range);
* ``beta`` > 1: expands (wide range).
What is left after removing the scaling part is the disagreement. Using the
disagreement (rather than the inflated raw interaction) as the error term gives
a more powerful product-effect F-test, and the ``beta`` coefficients let you
*align* the panel: rescale each panelist onto a common scale instead of
dropping them (:func:`align_scores`).
:func:`mixed_assessor_model` is the closed form on replicate-averaged cell means.
:func:`mixed_assessor_model_reml` is the random-effects version SensMixed fits:
REML with the panelist terms random, likelihood-ratio selection of the random
terms, and Satterthwaite F-tests, also for a product described by several
factors. Both are pure Python.
References
----------
Brockhoff, Schlich and Skovgaard, "Taking individual scaling differences into
account by analyzing profile data with the Mixed Assessor Model", Food Quality
and Preference, 39, 156-166, 2015.
Kuznetsova, Brockhoff and Christensen, "lmerTest package: tests in linear mixed
effects models", Journal of Statistical Software, 82(13), 2017.
"""
from __future__ import annotations
import math
import warnings
from collections.abc import Sequence
from dataclasses import dataclass
from itertools import combinations
import numpy as np
import pandas as pd
from scipy.stats import chi2
from scipy.stats import f as f_dist
from threadpoolctl import threadpool_limits
from process_improve._warnings import user_stacklevel
from process_improve.regression._robust_regression import repeated_median_slope
from process_improve.regression._variance_components import (
VarianceComponentFit,
fit_variance_components,
independent_columns,
type1_hypotheses,
)
#: Slopes at or below this are treated as unusable for rescaling (a panelist
#: who is flat or anti-correlated with the panel cannot be scale-corrected).
_MIN_SLOPE = 0.2
#: Columns :func:`mixed_assessor_model` reads out of the long-format panel.
_REQUIRED_COLUMNS: tuple[str, ...] = ("panelist_id", "product", "attribute", "score")
AlignMethod = str # "both" | "location" | "scale"
#: Why an attribute with a missing cell has NaN F-tests: said in its ``note`` and in the warning.
_INCOMPLETE_TABLE = (
"The closed-form F-tests need a complete panelist-by-product table, so they are NaN for an attribute "
"with a missing cell. Use mixed_assessor_model_reml, which handles missing cells."
)
[docs]
@dataclass
class MAMResult:
"""Outcome of :func:`mixed_assessor_model`.
Attributes
----------
scaling : pandas.DataFrame
One row per (attribute, panelist) with the scaling coefficient
``beta``, the panelist ``offset`` from the attribute grand mean, and the
panelist ``mean``.
ftests : pandas.DataFrame
One row per attribute with the MAM and classical product-effect
F-tests: ``f_product_mam`` / ``p_product_mam`` (disagreement as error)
and ``f_product_classical`` / ``p_product_classical`` (raw interaction
as error), plus the degrees of freedom. The four F-test columns are NaN
for an attribute where a panelist did not score every product. The
``note`` column says why an F-test is NaN (the panelists and products
of the missing cells, or a panel too small to test), and is empty when
both F-tests are computed.
"""
scaling: pd.DataFrame
ftests: pd.DataFrame
def _cell_means(panel: pd.DataFrame, attribute: str) -> pd.DataFrame:
"""Return the panelist-by-product matrix of replicate-averaged scores."""
sub = panel[panel["attribute"] == attribute]
return sub.pivot_table(index="panelist_id", columns="product", values="score", aggfunc="mean", observed=True)
def _assessor_scaling(matrix: pd.DataFrame) -> tuple[pd.Series, pd.Series, np.ndarray, float]:
"""Return per-panelist slopes and means, the centred product effect, and its SSQ.
``matrix`` is panelists (rows) by products (columns) of cell means.
"""
consensus = matrix.mean(axis=0)
tau = (consensus - consensus.mean()).to_numpy() # centred product effect
ssq_tau = float(np.nansum(tau**2))
means = matrix.mean(axis=1)
if ssq_tau > 0:
centred = matrix.to_numpy() - means.to_numpy()[:, None]
slopes = np.nansum(centred * tau[None, :], axis=1) / ssq_tau
else:
slopes = np.ones(matrix.shape[0])
beta = pd.Series(slopes, index=matrix.index)
return beta, means, tau, ssq_tau
#: What each F-test's error term needs to have any degrees of freedom.
_ERROR_NEEDS = {"disagreement": "at least 2 panelists and 3 products", "interaction": "at least 2 panelists"}
def _ftest(ss_product: float, df_product: int, error_ss: float, error_df: int, error: str) -> tuple[float, float, str]:
"""Return the product-effect F, its p-value, and why they are NaN (empty when they are not)."""
if df_product <= 0:
return float("nan"), float("nan"), "there is only one product, so no product effect to test"
if error_df <= 0:
return float("nan"), float("nan"), f"the {error} has no degrees of freedom (it needs {_ERROR_NEEDS[error]})"
if error_ss <= 0:
return float("nan"), float("nan"), f"the {error} sum of squares is zero, so the F ratio is undefined"
f_value = (ss_product / df_product) / (error_ss / error_df)
return float(f_value), float(f_dist.sf(f_value, df_product, error_df)), ""
def _missing_cells(matrix: pd.DataFrame) -> str:
"""Name each panelist of ``matrix`` with no score for some products, and those products."""
absent = matrix.isna()
return "; ".join(
f"panelist {str(pid)!r} did not score product(s) {[str(p) for p in matrix.columns[absent.loc[pid]]]}"
for pid in matrix.index[absent.any(axis=1)]
)
[docs]
def mixed_assessor_model(panel: pd.DataFrame) -> MAMResult:
"""Fit the Mixed Assessor Model per attribute.
Parameters
----------
panel : pandas.DataFrame
Validated ``descriptive_long`` panel data.
Returns
-------
MAMResult
Per-panelist scaling coefficients and per-attribute F-tests; see the
class docstring.
Raises
------
ValueError
If a required column is missing, or the panel has no rows. An empty
panel is reachable whenever an upstream filter removes every attribute;
the frames built from it would have no columns at all, so the caller
would meet the problem as a ``KeyError`` on ``ftests["f_product_mam"]``
rather than here.
Warns
-----
RuntimeWarning
If, for some attribute, a panelist did not score every product. The
sums of squares behind the F-tests add up only on a complete
panelist-by-product table, so that attribute's F-tests are NaN; the
scaling coefficients are still estimated from the cells present. The
warning, and the attribute's ``note``, name the panelists and products
of the missing cells. :func:`mixed_assessor_model_reml` handles missing
cells.
Examples
--------
>>> mam = mixed_assessor_model(validated.normalized_df)
>>> mam.scaling.query("attribute == 'saltiness'").sort_values("beta").head()
"""
missing = [column for column in _REQUIRED_COLUMNS if column not in panel.columns]
if missing:
raise ValueError(
f"mixed_assessor_model needs the long-format panel columns {list(_REQUIRED_COLUMNS)}; "
f"missing {missing}. Got columns {list(panel.columns)}."
)
if len(panel) == 0:
raise ValueError(
"mixed_assessor_model was given a panel with no rows, so there is no attribute "
"to fit. This usually means an upstream filter (an attribute list, a panelist "
"exclusion, a product subset) removed everything; check that filter rather than "
"this call."
)
scaling_rows: list[dict[str, object]] = []
ftest_rows: list[dict[str, object]] = []
incomplete: dict[str, list[str]] = {} # the missing cells, and the attributes that miss them
for attribute in sorted(panel["attribute"].unique()):
matrix = _cell_means(panel, attribute)
n_assessors, n_products = matrix.shape
beta, means, _tau, ssq_tau = _assessor_scaling(matrix)
grand = float(np.nanmean(matrix.to_numpy()))
scaling_rows.extend(
{
"attribute": str(attribute),
"panelist_id": str(pid),
"beta": float(beta.loc[pid]),
"offset": float(means.loc[pid] - grand),
"mean": float(means.loc[pid]),
}
for pid in matrix.index
)
# Two-way decomposition of the cell-mean table.
values = matrix.to_numpy()
product_effect = np.nanmean(values, axis=0) - grand
assessor_effect = np.nanmean(values, axis=1) - grand
ss_product = n_assessors * float(np.nansum(product_effect**2))
ss_assessor = n_products * float(np.nansum(assessor_effect**2))
ss_total = float(np.nansum((values - grand) ** 2))
ss_interaction = ss_total - ss_product - ss_assessor
# MAM split of the interaction: scaling (slope deviations) + disagreement.
ss_scaling = float(np.nansum((beta.to_numpy() - 1.0) ** 2)) * ssq_tau
ss_disagreement = ss_interaction - ss_scaling
df_product = n_products - 1
df_interaction = (n_assessors - 1) * (n_products - 1)
df_disagreement = (n_assessors - 1) * (n_products - 2)
f_mam, p_mam, why_mam = _ftest(ss_product, df_product, ss_disagreement, df_disagreement, "disagreement")
f_classical, p_classical, why_classical = _ftest(
ss_product, df_product, ss_interaction, df_interaction, "interaction"
)
why = {"MAM": why_mam, "classical": why_classical}
note = "; ".join(f"{test} F-test: {reason}" for test, reason in why.items() if reason)
if why_mam and why_mam == why_classical:
note = f"Both F-tests: {why_mam}"
if matrix.isna().to_numpy().any():
# ss_interaction is the remainder of ss_total after the product and assessor sums,
# which holds only for a complete table: with a missing cell the F-tests were
# sometimes NaN and sometimes a finite number on the wrong degrees of freedom.
cells = _missing_cells(matrix)
incomplete.setdefault(cells, []).append(str(attribute))
f_mam = p_mam = f_classical = p_classical = float("nan")
note = f"{cells}. {_INCOMPLETE_TABLE}"
ftest_rows.append(
{
"attribute": str(attribute),
"f_product_mam": f_mam,
"p_product_mam": p_mam,
"f_product_classical": f_classical,
"p_product_classical": p_classical,
"df_product": int(df_product),
"df_disagreement": int(df_disagreement),
"note": note,
}
)
if incomplete:
where = "; ".join(f"in attribute(s) {attributes}, {cells}" for cells, attributes in incomplete.items())
warnings.warn(
f"mixed_assessor_model: a panelist did not score every product: {where}. {_INCOMPLETE_TABLE}",
category=RuntimeWarning,
stacklevel=user_stacklevel(),
)
return MAMResult(scaling=pd.DataFrame(scaling_rows), ftests=pd.DataFrame(ftest_rows))
[docs]
def align_scores(panel: pd.DataFrame, *, method: AlignMethod = "both", robust: bool = False) -> pd.DataFrame:
"""Harmonize every panelist's scores onto the common panel scale.
For each attribute and panelist, the location lever removes the panelist's
mean offset (so "rates everything high/low" goes away) and the scale lever
divides by the panelist's scaling coefficient ``beta`` (so a compressor's
narrow range is stretched toward the panel's). This rescales the whole panel
(standard MAM practice), keeping panelists rather than dropping them.
When a panelist is flat or anti-correlated with the panel (``beta`` is not
finite or below ``_MIN_SLOPE``), the scale lever is skipped for that
panelist and the fallback depends on ``method``:
- ``"both"``: the panelist is left location-corrected only.
- ``"scale"``: the panelist is left as-is (no correction applied).
- ``"location"``: the slope is not consulted, so the location correction
is always applied.
Parameters
----------
panel : pandas.DataFrame
Validated ``descriptive_long`` panel data.
method : {"both", "location", "scale"}
``"location"`` recentres each panelist to the grand mean; ``"scale"``
rescales the spread around the panelist's own mean; ``"both"`` (default)
does both, the full MAM alignment.
robust : bool
Use the repeated-median slope for ``beta`` instead of least squares.
Returns
-------
pandas.DataFrame
A corrected copy of ``panel`` with the ``score`` column aligned.
"""
if method not in ("both", "location", "scale"):
raise ValueError(f"method must be 'both', 'location', or 'scale', got {method!r}.")
out = panel.copy()
for attribute in panel["attribute"].unique():
matrix = _cell_means(panel, attribute)
beta, means, _tau, ssq_tau = _assessor_scaling(matrix)
grand = float(np.nanmean(matrix.to_numpy()))
if robust and ssq_tau > 0:
consensus = matrix.mean(axis=0)
centred_tau = (consensus - consensus.mean()).to_numpy()
beta = pd.Series(
[repeated_median_slope(centred_tau, matrix.loc[pid].to_numpy(), nowarn=True) for pid in matrix.index],
index=matrix.index,
)
mask = panel["attribute"] == attribute
for pid in matrix.index:
slope = float(beta.loc[pid])
if not np.isfinite(slope) or slope < _MIN_SLOPE:
slope = 1.0 # cannot rescale a flat / anti-correlated panelist
mean_i = float(means.loc[pid])
rows = mask & (panel["panelist_id"] == pid)
raw = panel.loc[rows, "score"]
if method == "location":
out.loc[rows, "score"] = raw - mean_i + grand
elif method == "scale":
out.loc[rows, "score"] = mean_i + (raw - mean_i) / slope
else: # both
out.loc[rows, "score"] = grand + (raw - mean_i) / slope
return out
# ---------------------------------------------------------------------------
# The random-effects MAM (SensMixed)
# ---------------------------------------------------------------------------
#: SensMixed drops a random term whose estimated standard deviation is below this
#: before testing anything.
_ZERO_SD = 1e-7
#: Fewer product cells than this leave no disagreement once scaling is removed.
_MIN_PRODUCTS = 3
[docs]
@dataclass
class MAMRemlResult:
"""Outcome of :func:`mixed_assessor_model_reml`.
Attributes
----------
anova : pandas.DataFrame
The final model's sequential (Type I) F-tests, one row per attribute and term:
each product term, then ``scaling``. Columns ``attribute``, ``term``, ``sum_sq``,
``mean_sq``, ``num_df``, ``den_df`` (Satterthwaite), ``f_value``, ``p_value``.
``scaling`` tests whether the panelists' scaling coefficients differ.
random_effects : pandas.DataFrame
One row per attribute and candidate random term: ``chi_sq``, ``chi_df`` and
``p_value`` of its likelihood-ratio test, ``status`` (``"kept"``,
``"eliminated"`` or ``"zero_variance"``) and ``step``, the order of elimination
(``<NA>`` unless eliminated). An eliminated term shows the test that removed it;
a kept term, its test in the final model; a zero-variance term was dropped
before any test and has none.
variance_components : pandas.DataFrame
The final model's variances, one row per attribute and random term, then
``Residual``: columns ``attribute``, ``term``, ``variance``, ``std_dev``.
scaling : pandas.DataFrame
One row per attribute and panelist: the scaling coefficient ``beta``, scaled
to average 1 over the panel as in :func:`mixed_assessor_model`.
"""
anova: pd.DataFrame
random_effects: pd.DataFrame
variance_components: pd.DataFrame
scaling: pd.DataFrame
def _term_label(columns: Sequence[str]) -> str:
"""Display name of a model term; ``panelist_id`` reads as ``panelist``."""
return ":".join("panelist" if column == "panelist_id" else column for column in columns)
def _term_columns(frame: pd.DataFrame, factors: Sequence[str]) -> np.ndarray:
"""Model columns of a product term, coded as lmerTest codes them.
Each factor gets an indicator column for every level but the last (SAS coding);
an interaction is the products of its factors' columns, the first factor varying
fastest, as in R's ``model.matrix``.
"""
block = np.ones((len(frame), 1))
for factor in factors:
values = frame[factor].to_numpy()
coded = (values[:, None] == np.sort(frame[factor].unique())[None, :-1]).astype(float)
block = (coded[:, :, None] * block[:, None, :]).reshape(len(frame), -1)
return block
def _fixed_design(
frame: pd.DataFrame, product_terms: list[tuple[str, ...]], panelists: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Return the fixed-effect matrix, each column's term number, and which scaling columns survive.
The terms are the intercept, the product terms, and ``scaling``: the centred product
cell means times each panelist's indicator, so each panelist gets a slope on the
consensus. Those slopes sum to the consensus itself, which the product terms
already span, so one panelist's column is aliased and dropped (the last, as lme4
drops it); the slopes are identified only relative to each other.
"""
y = frame["score"].to_numpy(dtype=float)
blocks, assign = [np.ones((len(frame), 1))], [-1]
for number, factors in enumerate(product_terms):
columns = _term_columns(frame, factors)
blocks.append(columns)
assign.extend([number] * columns.shape[1])
consensus = frame.groupby(list(product_terms[-1]), observed=True)["score"].transform("mean").to_numpy() - y.mean()
blocks.append(consensus[:, None] * (frame["panelist_id"].to_numpy()[:, None] == panelists[None, :]))
assign.extend([len(product_terms)] * len(panelists))
X, assign_array = np.hstack(blocks), np.array(assign)
keep = independent_columns(X)
return X[:, keep], assign_array[keep], keep[-len(panelists) :]
def _random_terms(
frame: pd.DataFrame, product_terms: list[tuple[str, ...]], replication: str | None
) -> list[tuple[str, ...]]:
"""SensMixed's candidate random terms, in its order.
Every product term crossed with the panelist, the replicate and both, then those
three on their own; a term with as many levels as observations is the residual
and is left out.
"""
random_sets: list[tuple[str, ...]] = [("panelist_id",)]
if replication is not None:
random_sets += [(replication,), ("panelist_id", replication)]
candidates = [(*term, *random_set) for term in product_terms for random_set in random_sets]
candidates += random_sets
return [term for term in candidates if math.prod(frame[column].nunique() for column in term) < len(frame)]
class _AttributeModel:
"""The REML fits of one attribute's MAM, for any subset of its random terms."""
def __init__(self, frame: pd.DataFrame, product_terms: list[tuple[str, ...]], replication: str | None) -> None:
self.y = frame["score"].to_numpy(dtype=float)
self.panelists = np.sort(frame["panelist_id"].unique())
self.X, self.assign, self.scaling_kept = _fixed_design(frame, product_terms, self.panelists)
self.terms = _random_terms(frame, product_terms, replication)
self.codes = {term: frame.groupby(list(term), sort=True).ngroup().to_numpy() for term in self.terms}
def fit(
self, terms: list[tuple[str, ...]], start: dict[tuple[str, ...], float] | None = None
) -> VarianceComponentFit:
"""REML fit with the random ``terms``, from the variance ratios ``start`` when given.
A zero ratio restarts at a small positive one, so the search can leave the boundary.
"""
psi = None if start is None else np.array([max(start.get(term, 1.0), 1e-3) for term in terms])
return fit_variance_components(self.y, self.X, [self.codes[term] for term in terms], start=psi)
def _ratios(terms: list[tuple[str, ...]], fit: VarianceComponentFit) -> dict[tuple[str, ...], float]:
"""Each term's variance relative to the residual: the fit's ``psi``, to warm-start another."""
return dict(zip(terms, fit.variances / fit.sigma2, strict=True))
def _likelihood_ratio(
model: _AttributeModel, terms: list[tuple[str, ...]], current: VarianceComponentFit
) -> dict[tuple[str, ...], tuple[float, float, VarianceComponentFit]]:
"""Test each term by refitting without it: the REML criterion's rise, on 1 df."""
tests = {}
start = _ratios(terms, current)
for term in terms:
reduced = model.fit([other for other in terms if other != term], start=start)
chi_sq = max(reduced.reml_criterion - current.reml_criterion, 0.0)
tests[term] = (chi_sq, float(chi2.sf(chi_sq, 1)), reduced)
return tests
def _record(term: tuple[str, ...], status: str, test: tuple | None = None, step: int | None = None) -> dict:
"""One row of the random-effects table (without the attribute)."""
return {
"term": _term_label(term),
"chi_sq": np.nan if test is None else test[0],
"chi_df": 1,
"p_value": np.nan if test is None else test[1],
"status": status,
"step": pd.NA if step is None else step,
}
def _select_random(
model: _AttributeModel, keep: set[tuple[str, ...]], alpha: float
) -> tuple[list[tuple[str, ...]], VarianceComponentFit, list[dict]]:
"""Drop zero-variance terms, then eliminate the least significant term while its p exceeds ``alpha``.
This is SensMixed's selection (lmerTest's ``step``): terms in ``keep`` are tested
but never eliminated, and the procedure stops when every remaining candidate is
significant. Returns the final terms, their fit, and one record per candidate.
The terms in ``keep`` are not dropped for a zero variance either. SensMixed does
drop them, but only when lme4's search ends within 1e-7 of the boundary, which it
often does not; an exact optimum sits on it. A kept term matters even at zero:
when another term is tested, the panelist term can take up the variation it leaves.
"""
full = model.fit(model.terms)
zero = {
term
for term, variance in zip(model.terms, full.variances, strict=True)
if math.sqrt(variance) < _ZERO_SD and term not in keep
}
records = [_record(term, "zero_variance") for term in model.terms if term in zero]
active = [term for term in model.terms if term not in zero]
current = model.fit(active, start=_ratios(model.terms, full)) if zero else full
step = 0
while True: # each pass eliminates a candidate, or stops
tests = _likelihood_ratio(model, active, current)
candidates = [term for term in active if term not in keep]
worst = max(candidates, key=lambda term: tests[term][1], default=None)
if worst is None or tests[worst][1] <= alpha:
break
step += 1
records.append(_record(worst, "eliminated", tests[worst], step))
current = tests[worst][2]
active.remove(worst)
records += [_record(term, "kept", tests[term]) for term in active]
return active, current, records
def _attribute_tables(
attribute: str,
model: _AttributeModel,
terms: list[tuple[str, ...]],
fit: VarianceComponentFit,
product_terms: list[tuple[str, ...]],
) -> tuple[list[dict], list[dict], list[dict]]:
"""Type I tests, variance components and scaling coefficients of the final model."""
labels = [":".join(term) for term in product_terms] + ["scaling"]
anova = []
for number, contrasts in type1_hypotheses(model.X, model.assign).items():
f_value, den_df = fit.f_test(contrasts)
num_df = contrasts.shape[0]
anova.append(
{
"attribute": attribute,
"term": labels[number],
"sum_sq": f_value * fit.sigma2 * num_df,
"mean_sq": f_value * fit.sigma2,
"num_df": num_df,
"den_df": den_df,
"f_value": f_value,
"p_value": float(f_dist.sf(f_value, num_df, den_df)),
}
)
components = [
{"attribute": attribute, "term": _term_label(term), "variance": float(variance)}
for term, variance in zip(terms, fit.variances, strict=True)
]
components.append({"attribute": attribute, "term": "Residual", "variance": fit.sigma2})
slopes = np.zeros(len(model.panelists))
slopes[model.scaling_kept] = fit.beta[model.assign == len(product_terms)]
scaling = [
{"attribute": attribute, "panelist_id": str(panelist), "beta": float(beta)}
for panelist, beta in zip(model.panelists, 1.0 + slopes - slopes.mean(), strict=True)
]
return anova, components, scaling
def _check_reml_inputs(panel: pd.DataFrame, product_factors: Sequence[str], replication: str | None) -> None:
"""Raise a ValueError naming what is missing or unusable."""
needed = ["panelist_id", "attribute", "score", *product_factors]
if replication is not None:
needed.append(replication)
missing = [column for column in needed if column not in panel.columns]
if missing:
hint = " Pass replication=None for a panel without replicates." if replication in missing else ""
raise ValueError(
f"mixed_assessor_model_reml needs the columns {needed}; missing {missing}.{hint} "
f"Got columns {list(panel.columns)}."
)
if len(panel) == 0:
raise ValueError("mixed_assessor_model_reml was given a panel with no rows, so there is no attribute to fit.")
def _attribute_frame(panel: pd.DataFrame, attribute: object, product_factors: tuple[str, ...]) -> pd.DataFrame:
"""Return one attribute's scored rows, checking it has enough products for the MAM."""
frame = panel.loc[panel["attribute"] == attribute].dropna(subset=["score"]).reset_index(drop=True)
n_products = frame.groupby(list(product_factors), observed=True).ngroups
if n_products < _MIN_PRODUCTS:
raise ValueError(
f"The MAM needs at least {_MIN_PRODUCTS} products to separate scaling from disagreement; "
f"attribute {attribute!r} has scores for {n_products} (product factors {list(product_factors)})."
)
return frame
[docs]
def mixed_assessor_model_reml(
panel: pd.DataFrame,
*,
product_factors: Sequence[str] = ("product",),
replication: str | None = "replicate",
alpha_random: float = 0.1,
) -> MAMRemlResult:
"""Fit the random-effects Mixed Assessor Model per attribute, as SensMixed does.
The model for one attribute has the product terms (every main effect and
interaction of ``product_factors``) and a ``scaling`` term as fixed effects. The
scaling term gives each panelist a slope on the centred product means, so a
panelist who uses a wider or narrower part of the scale is modelled, not counted
as disagreement. The random terms are each product term crossed with the
panelist, the replicate and both, and those three on their own.
The random part is then reduced as SensMixed reduces it:
1. terms whose standard deviation is estimated as zero are dropped;
2. each remaining term is tested by a likelihood-ratio test (REML, 1 df), and
the least significant is eliminated while its p-value exceeds
``alpha_random``. The panelist and the product-by-panelist terms are tested
but always kept.
The fixed terms of the final model are tested in sequence (Type I), with
Satterthwaite's denominator degrees of freedom. For a balanced panel and a
one-factor product this reproduces the closed form of
:func:`mixed_assessor_model`; with several product factors each gets the error
term the selected random effects imply, which the closed form cannot do.
Parameters
----------
panel : pandas.DataFrame
Long-format panel data with ``panelist_id``, ``attribute``, ``score`` and the
product and replicate columns named below.
product_factors : sequence of str
The column(s) describing the products. The default is the ``product`` column;
for products from a factorial design, name its factors (for example
``("tv_set", "picture")``) and every product term is tested separately.
replication : str or None
The replicate (session) column. A replicate is taken to be shared by the
panelists, as a session is, so the replicate gets random effects of its own.
``None`` (or a column with one level) fits the panelist terms only.
alpha_random : float
A random term is eliminated while its likelihood-ratio p-value exceeds this.
``1.0`` keeps every term.
Returns
-------
MAMRemlResult
The F-tests, the random-term selection, the variance components and the
scaling coefficients; see the class docstring.
Raises
------
ValueError
If a column is missing, the panel is empty, or an attribute has scores for
fewer than three products.
Notes
-----
This reproduces ``sensmixed(..., MAM = TRUE)`` of the R package SensMixed 2.1
with its defaults (all product interactions; replicate error structure; no
separate scaling per product factor), which runs lme4 and lmerTest. The test
suite checks it against SensMixed on the TVbo panel. The estimates are exact
REML optima, where lme4's are numerical; they agree to about 1e-6.
Examples
--------
>>> mam = mixed_assessor_model_reml(panel, product_factors=("tv_set", "picture"))
>>> mam.anova.query("attribute == 'sharpness'")
>>> mam.random_effects.query("status == 'eliminated'")
"""
product_factors = tuple(product_factors)
_check_reml_inputs(panel, product_factors, replication)
product_terms = [
terms for size in range(1, len(product_factors) + 1) for terms in combinations(product_factors, size)
]
keep = {(*product_factors, "panelist_id"), ("panelist_id",)}
tables: dict[str, list[dict]] = {"anova": [], "random": [], "components": [], "scaling": []}
# Hundreds of small dense fits: waking a BLAS thread pool for each call costs more
# than the arithmetic (ten times the run time, measured on four cores).
with threadpool_limits(limits=1, user_api="blas"):
for attribute in sorted(panel["attribute"].unique()):
frame = _attribute_frame(panel, attribute, product_factors)
rep = replication if replication is not None and frame[replication].unique().size > 1 else None
model = _AttributeModel(frame, product_terms, rep)
terms, fit, records = _select_random(model, keep, alpha_random)
tables["random"] += [{"attribute": str(attribute), **record} for record in records]
anova, components, scaling = _attribute_tables(str(attribute), model, terms, fit, product_terms)
tables["anova"] += anova
tables["components"] += components
tables["scaling"] += scaling
random_effects = pd.DataFrame(tables["random"])
random_effects["step"] = random_effects["step"].astype("Int64")
variance_components = pd.DataFrame(tables["components"])
variance_components["std_dev"] = np.sqrt(variance_components["variance"])
return MAMRemlResult(
anova=pd.DataFrame(tables["anova"]),
random_effects=random_effects,
variance_components=variance_components,
scaling=pd.DataFrame(tables["scaling"]),
)