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
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ those changes.

## [Unreleased]

## [1.60.0] - 2026-07-23

### Added

- New module `process_improve.sensory.designed` for designed-mode comparison of
descriptive panel data, the complement to the existing observational relate. It
answers "which product treatments differ, and by how much, on each attribute" for a
randomized complete block design (the same panelists score every treatment, with
panelist as the block). Public entry points, all generic in the factor column names:
- `factorial_anova(panel, *, factors, block, interactions)`: per-attribute Type III
factorial ANOVA (`score ~ C(factor_1) * C(factor_2) * ... + C(block)`), so an
unbalanced grid is handled correctly and the interaction terms test whether one
factor's effect depends on another (e.g. does aging change some formulations more
than others).
- `tukey_hsd(panel, *, factor, block, alpha)`: all-pairwise Tukey HSD using the
blocked-model error mean square and the studentized-range distribution (the block
variance is removed), with a compact-letter display grouping treatments that are
not separable.
- `dunnett_vs_control(panel, *, factor, control, alpha)`: Dunnett's two-sided test of
every treatment against one named control.
- `compare_products(...)` orchestrates the ANOVA plus post-hoc tests and returns a
`ComparisonResult`; a `within` argument runs the post-hoc tests as simple effects
within each level of another factor (the right follow-up once an interaction is
significant).
- Documentation: a "Comparing designed treatments" section in the sensory panel
user guide with a worked Tukey HSD versus Dunnett example.

## [1.59.0] - 2026-07-23

### Added
Expand Down Expand Up @@ -2625,7 +2652,8 @@ this entry records them together.
- Reworked the README with a sharper value proposition and a
"Why not scikit-learn?" comparison table.

[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.59.0...HEAD
[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.60.0...HEAD
[1.60.0]: https://github.com/kgdunn/process-improve/compare/v1.59.0...v1.60.0
[1.59.0]: https://github.com/kgdunn/process-improve/compare/v1.58.0...v1.59.0
[1.58.0]: https://github.com/kgdunn/process-improve/compare/v1.57.0...v1.58.0
[1.57.0]: https://github.com/kgdunn/process-improve/compare/v1.56.0...v1.57.0
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ authors:
repository-code: "https://github.com/kgdunn/process-improve"
url: "https://kgdunn.github.io/process-improve/"
license: MIT
version: 1.59.0
version: 1.60.0
date-released: "2026-07-23"
keywords:
- chemometrics
Expand Down
144 changes: 144 additions & 0 deletions docs/user_guide/sensory_panel.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ Keep the interpretation in mind: an observational analysis supports only "this
descriptor is associated with this attribute", whereas the future designed
analysis will support "increasing this factor raises this attribute".

A related but separate question does not need the covariate table at all: when
the products *are* the controlled treatments (for example five formulations, or
a formulation crossed with an aging condition) and you simply want to know which
treatments differ on each attribute, use the direct factorial-comparison API in
:func:`~process_improve.sensory.compare_products` (see
:ref:`comparing-designed-treatments` below). That path is implemented now; the
``mode="designed"`` relate switch above is the still-planned integration of the
same idea into the covariate-driven pipeline.

Step 0: get the data into long form
-----------------------------------

Expand Down Expand Up @@ -262,6 +271,141 @@ Rescaling does not remove genuine disagreement, so a panelist who truly ranks
the products differently is better handled by dropping (``drop_panelists`` or
``correction="drop"``); align and drop can be combined.

.. _comparing-designed-treatments:

Comparing designed treatments: ANOVA, Tukey HSD and Dunnett
-----------------------------------------------------------

When the products are controlled treatments rather than measured market samples
(five formulations, a formulation crossed with an aging condition, a process
setting), and the same panelists score every treatment, the design is a
**randomized complete block** with panelist as the block. The question is which
treatments differ, and by how much, on each attribute.
:func:`~process_improve.sensory.compare_products` answers it with a per-attribute
factorial ANOVA followed by the matching post-hoc multiple comparisons; the
pieces are also usable on their own
(:func:`~process_improve.sensory.factorial_anova`,
:func:`~process_improve.sensory.tukey_hsd`,
:func:`~process_improve.sensory.dunnett_vs_control`).

Per attribute the model is a Type III factorial ANOVA

.. math::

\text{score} \sim C(\text{factor}_1) \times C(\text{factor}_2) \times \dots + C(\text{block})

Type III sums of squares keep the effects correct when the grid is unbalanced (a
panelist who skipped a sample, a missing cell), and the interaction terms test
whether one factor's effect depends on another, for example whether aging shifts
some formulations more than others.

.. code-block:: python

from process_improve.sensory import compare_products

# panel: descriptive_long, with extra factor columns "formulation" and "condition".
result = compare_products(
panel,
factors=["formulation", "condition"],
block="panelist_id",
within="condition", # run the post-hoc tests within each condition (simple effects)
control="Control", # the reference level for Dunnett
)

result.anova # Type III ANOVA table, one row per (attribute, source)
result.tukey # all-pairwise Tukey HSD contrasts
result.dunnett # each formulation vs the Control
result.letters # compact-letter display: shared letter => not separable
result.means # per-level mean with a confidence interval

Set ``within="condition"`` to run the post-hoc tests as *simple effects*
separately within each aging condition, which is the right follow-up once the
``formulation`` x ``condition`` interaction is significant; leave it ``None`` to
compare the primary factor pooled over the others.

Two post-hoc procedures, two questions
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Both procedures control the family-wise error rate (the chance of *any* false
claim across the whole family of comparisons), but over different families:

- **Tukey HSD** compares **every pair** of treatments. Its critical difference
uses the blocked-model error mean square :math:`\text{MSE}` and the
studentized-range distribution :math:`q`, so the panelist-block variance is
removed from the yardstick:

.. math::

|\bar{y}_i - \bar{y}_j| \;>\; q_{\alpha,\,g,\,\nu}\,\sqrt{\text{MSE}/n}

where :math:`g` is the number of treatments and :math:`\nu` the error degrees
of freedom. The compact-letter display in ``result.letters`` summarises it:
treatments that share a letter are not separable.

- **Dunnett** compares **each treatment to one control**. Because those
comparisons all share the control mean they are correlated, so the critical
value comes from the multivariate-*t* distribution rather than the studentized
range. Guarding fewer comparisons makes Dunnett more powerful than Tukey for
the treatment-vs-control question, but it cannot compare two treatments to each
other.

A rule of thumb: use **Dunnett** when there is a genuine reference you are
benchmarking against (a Control formulation, or REF for the aging axis), and
**Tukey** when the treatments are peers and you want the full pairwise map.
Running both is common; report them as answering different questions.

Worked numbers
~~~~~~~~~~~~~~~

Take one attribute scored by five formulations, seven panelists each, with an
error mean square of :math:`\text{MSE}=1.0` on :math:`\nu=30` degrees of freedom.
The level means are:

========= ======= ==== ==== ==== ====
Statistic Control T1 T2 T3 T4
========= ======= ==== ==== ==== ====
mean 3.0 3.3 3.5 4.5 6.2
========= ======= ==== ==== ==== ====

The standard error of a group mean is :math:`\sqrt{\text{MSE}/n}=\sqrt{1/7}=0.378`
and of a difference is :math:`\sqrt{2\,\text{MSE}/n}=0.535`.

*Tukey.* With :math:`q_{0.05,\,5,\,30}=4.102`, the critical difference is
:math:`4.102 \times 0.378 = 1.55`. Only the gaps involving T4 exceed it, so the
letter display is T4 = ``a`` and Control, T1, T2, T3 = ``b``.

*Dunnett.* The two-sided critical value for four treatments versus one control at
:math:`\nu=30` is about :math:`2.18`, giving a critical difference of
:math:`2.18 \times 0.535 = 1.16`.

The two cutoffs disagree on exactly one comparison:

=============== ========== ===================== ========================
Comparison Difference Tukey (cutoff 1.55) Dunnett (cutoff 1.16)
=============== ========== ===================== ========================
T1 - Control 0.30 not significant not significant
T2 - Control 0.50 not significant not significant
T3 - Control 1.50 not significant significant (p=0.03)
T4 - Control 3.20 significant significant
=============== ========== ===================== ========================

T3 sits 1.50 above the Control. Tukey, which must protect all ten pairwise
comparisons, raises its bar to 1.55 and calls it non-significant; Dunnett, which
protects only the four treatment-vs-control comparisons, has a lower bar (1.16)
and flags it. Same data, both correct: the procedures answer different questions
with correctly calibrated thresholds.

.. rubric:: Post-hoc references

- Tukey, J. W. (1949). Comparing individual means in the analysis of variance.
*Biometrics*, 5(2), 99-114.
- Dunnett, C. W. (1955). A multiple comparison procedure for comparing several
treatments with a control. *Journal of the American Statistical Association*,
50(272), 1096-1121.
- Piepho, H.-P. (2004). An algorithm for a letter-based representation of
all-pairwise comparisons. *Journal of Computational and Graphical Statistics*,
13(2), 456-466.

Worked example
--------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "process-improve"
version = "1.59.0"
version = "1.60.0"
description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.'
readme = "README.md"
license = "MIT"
Expand Down
12 changes: 12 additions & 0 deletions src/process_improve/sensory/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@
relate_designed,
relate_observational,
)
from process_improve.sensory.designed import (
ComparisonResult,
compare_products,
dunnett_vs_control,
factorial_anova,
tukey_hsd,
)
from process_improve.sensory.ingest import reshape_to_long
from process_improve.sensory.mam import MAMResult, align_scores, mixed_assessor_model
from process_improve.sensory.panel import PanelScorecard, apply_correction, panel_scorecard
Expand All @@ -39,20 +46,25 @@
"DESCRIPTIVE_LONG_COLUMNS",
"SENSORY_RECIPES",
"AnalysisResult",
"ComparisonResult",
"MAMResult",
"PanelScorecard",
"ValidationResult",
"aggregate_to_product",
"align_scores",
"analyze_descriptive",
"apply_correction",
"compare_products",
"discriminate_observational",
"dunnett_vs_control",
"factorial_anova",
"mixed_assessor_model",
"panel_scorecard",
"permutation_column_null",
"product_means",
"relate_designed",
"relate_observational",
"reshape_to_long",
"tukey_hsd",
"validate_descriptive",
]
Loading
Loading