diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e561028..44ee1197 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,18 @@ those changes. ## [Unreleased] +## [1.61.0] - 2026-07-24 + +### Added + +- `sensory_compare_products`: an agent-callable (`@tool_spec`) wrapper exposing + the designed-mode comparison (`process_improve.sensory.compare_products`) as a + JSON-in / JSON-out tool, alongside the existing `sensory_*` tools. It runs the + per-attribute factorial ANOVA and the Tukey HSD / Dunnett-vs-control post-hoc + tests (with a `within` argument for simple effects) and returns the ANOVA + table, contrasts, compact-letter display and per-level means. Registered in + `get_sensory_tool_specs()` and the MCP server. + ## [1.60.0] - 2026-07-23 ### Added @@ -2652,7 +2664,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.60.0...HEAD +[Unreleased]: https://github.com/kgdunn/process-improve/compare/v1.61.0...HEAD +[1.61.0]: https://github.com/kgdunn/process-improve/compare/v1.60.0...v1.61.0 [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 diff --git a/CITATION.cff b/CITATION.cff index 3b94d0e9..08ba5d92 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -12,8 +12,8 @@ authors: repository-code: "https://github.com/kgdunn/process-improve" url: "https://kgdunn.github.io/process-improve/" license: MIT -version: 1.60.0 -date-released: "2026-07-23" +version: 1.61.0 +date-released: "2026-07-24" keywords: - chemometrics - multivariate analysis diff --git a/pyproject.toml b/pyproject.toml index deed236b..cdc0efac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "process-improve" -version = "1.60.0" +version = "1.61.0" description = 'Designed Experiments; Latent Variables (PCA, PLS, multivariate methods with missing data); Process Monitoring; Batch data analysis.' readme = "README.md" license = "MIT" diff --git a/src/process_improve/sensory/tools.py b/src/process_improve/sensory/tools.py index 8f5d2d8f..6695d34d 100644 --- a/src/process_improve/sensory/tools.py +++ b/src/process_improve/sensory/tools.py @@ -17,6 +17,7 @@ from pydantic import BaseModel, ConfigDict, Field from process_improve.sensory.analysis import analyze_descriptive as _analyze_descriptive +from process_improve.sensory.designed import compare_products as _compare_products from process_improve.sensory.ingest import reshape_to_long as _reshape_to_long from process_improve.sensory.mam import align_scores as _align_scores from process_improve.sensory.mam import mixed_assessor_model as _mixed_assessor_model @@ -235,9 +236,7 @@ class _AnalyzeInput(BaseModel): "Set false to skip it (faster)." ), ) - n_permutations: int = Field( - 199, ge=1, description="Permutations for the discriminator's selectivity-ratio null." - ) + n_permutations: int = Field(199, ge=1, description="Permutations for the discriminator's selectivity-ratio null.") random_state: int = Field(0, description="Seed for the discriminator's permutations and CV folds.") score_min: float | None = Field(None, description="Optional lower bound for the score scale.") score_max: float | None = Field(None, description="Optional upper bound for the score scale.") @@ -394,10 +393,136 @@ def sensory_panel_check(spec: _PanelCheckInput) -> dict: return clean(out) +class _CompareInput(BaseModel): + """Input contract for ``sensory_compare_products``.""" + + model_config = ConfigDict(extra="forbid") + + panel: list[dict[str, Any]] = Field( + ..., + min_length=1, + description=( + "Panel data as row-records, each with keys panelist_id, attribute, score, plus one column " + "per factor named in 'factors' (for example formulation, condition). No product-covariate " + "table is needed." + ), + ) + factors: list[str] = Field( + ..., + min_length=1, + description=( + "Fixed factor column names for the ANOVA, e.g. ['formulation', 'condition']. With more than " + "one factor and interactions=true their full crossed model is fitted, and the interaction " + "tests whether one factor's effect depends on another." + ), + ) + block: str | None = Field( + "panelist_id", + description="Blocking-factor column (default 'panelist_id'); null for no block.", + ) + primary: str | None = Field( + None, + description="Factor whose levels the post-hoc tests compare. Defaults to the first entry of 'factors'.", + ) + within: str | None = Field( + None, + description=( + "If set, run the post-hoc tests separately within each level of this factor (simple effects); " + "the right follow-up once the primary-by-within interaction is significant. If null, they " + "pool over the other factors." + ), + ) + control: str | None = Field( + None, + description="Level of the primary factor used as the Dunnett reference. If null, the Dunnett table is empty.", + ) + interactions: bool = Field(True, description="Include factor-by-factor interaction terms in the ANOVA.") + alpha: float = Field(0.05, gt=0, lt=1, description="Post-hoc family-wise significance level.") + conf_level: float = Field(0.95, gt=0, lt=1, description="Confidence level for the reported per-level means.") + + +@tool_spec( + name="sensory_compare_products", + description=( + "Compare controlled product treatments (a randomized complete block design: the same panelists " + "score every treatment) per attribute. Fits a per-attribute Type III factorial ANOVA " + "(score ~ factors, crossed, plus the block) and then post-hoc multiple comparisons on the " + "primary factor: all-pairwise Tukey HSD (which treatments differ from which, with a " + "compact-letter display) and, when a control is named, Dunnett's two-sided test of each " + "treatment against that control. Set 'within' to run the post-hoc tests as simple effects " + "inside each level of another factor (for example compare formulations within each aging " + "condition), which is the right follow-up once the interaction is significant. This is the " + "designed-treatment counterpart to sensory_analyze_descriptive's observational relate; no " + "product-covariate table is needed. " + "Returns: on bad input {ok: false, errors: [str]}. On success {ok: true, anova, tukey, dunnett, " + "letters, means, config}. 'anova' is rows of attribute, source, df, sum_sq, mean_sq, F, p_value " + "(the 'Residual' source carries the error term; F/p are null there). 'tukey' is rows of " + "[stratum], attribute, group1, group2, meandiff, se, q_stat, p_value, ci_low, ci_high, reject. " + "'dunnett' is rows of [stratum], attribute, level, control, meandiff, statistic, p_value, reject " + "(empty when no control was given). 'letters' is rows of [stratum], attribute, , letters " + "(treatments that share a letter are not separable). 'means' is rows of [stratum], attribute, " + ", mean, ci_low, ci_high, n. The stratum column is named after 'within' (or 'stratum', " + "value 'all', when within is null). 'config' echoes the resolved arguments." + ), + input_model=_CompareInput, + category="sensory", +) +def sensory_compare_products(spec: _CompareInput) -> dict: + """Factorial ANOVA plus Tukey / Dunnett post-hoc; see tool spec for details.""" + df = pd.DataFrame(spec.panel) + required = [ + *spec.factors, + *([spec.block] if spec.block else []), + *([spec.within] if spec.within else []), + "attribute", + "score", + ] + missing = [c for c in required if c not in df.columns] + if missing: + return clean({"ok": False, "errors": [f"Panel data is missing required columns: {missing}."]}) + + label_cols = {*spec.factors, "attribute"} + if spec.block: + label_cols.add(spec.block) + if spec.within: + label_cols.add(spec.within) + for col in label_cols: + df[col] = df[col].astype(str).str.strip() + df["score"] = pd.to_numeric(df["score"], errors="coerce") + + try: + result = _compare_products( + df, + factors=spec.factors, + block=spec.block, + primary=spec.primary, + within=spec.within, + control=spec.control, + interactions=spec.interactions, + alpha=spec.alpha, + conf_level=spec.conf_level, + ) + except (KeyError, ValueError) as exc: + return clean({"ok": False, "errors": [str(exc)]}) + + return clean( + { + "ok": True, + "anova": result.anova.to_dict(orient="records"), + "tukey": result.tukey.to_dict(orient="records"), + "dunnett": result.dunnett.to_dict(orient="records"), + "letters": result.letters.to_dict(orient="records"), + "means": result.means.to_dict(orient="records"), + "config": result.config, + } + ) + + _register("sensory_reshape_to_long") _register("sensory_validate_descriptive") _register("sensory_analyze_descriptive") _register("sensory_panel_check") +_register("sensory_compare_products") def get_sensory_tool_specs() -> list[dict]: diff --git a/tests/test_sensory.py b/tests/test_sensory.py index 35af93e3..0bae2fe0 100644 --- a/tests/test_sensory.py +++ b/tests/test_sensory.py @@ -118,12 +118,24 @@ def _leverage_case(seed: int = 1) -> tuple[pd.DataFrame, pd.DataFrame]: for j, prod in enumerate(LEVERAGE_PRODUCTS): for rep in (1, 2): rows.append( - {"panelist_id": pid, "session": 1, "product": prod, "attribute": "A", - "replicate": rep, "score": 5.0 + a_mean[j] + bias + rng.normal(0, 0.15)} + { + "panelist_id": pid, + "session": 1, + "product": prod, + "attribute": "A", + "replicate": rep, + "score": 5.0 + a_mean[j] + bias + rng.normal(0, 0.15), + } ) rows.append( - {"panelist_id": pid, "session": 1, "product": prod, "attribute": "C", - "replicate": rep, "score": 5.0 + c_mean[j] + bias + rng.normal(0, 0.15)} + { + "panelist_id": pid, + "session": 1, + "product": prod, + "attribute": "C", + "replicate": rep, + "score": 5.0 + c_mean[j] + bias + rng.normal(0, 0.15), + } ) return pd.DataFrame(rows), cov @@ -277,9 +289,7 @@ def test_dropping_panelist_changes_means(): dropped = analyze_descriptive(validated, drop_panelists="auto", discriminator=False) assert "P8" in dropped.dropped assert kept.product_means.shape == dropped.product_means.shape - merged = kept.product_means.merge( - dropped.product_means, on=["product", "attribute"], suffixes=("_keep", "_drop") - ) + merged = kept.product_means.merge(dropped.product_means, on=["product", "attribute"], suffixes=("_keep", "_drop")) assert not np.allclose(merged["mean_keep"], merged["mean_drop"]) @@ -319,9 +329,7 @@ def test_relate_observational_q_values_monotone(): def test_collinear_clusters_groups_correlated_descriptors(): rng = np.random.default_rng(0) base = rng.standard_normal(20) - block = pd.DataFrame( - {"a": base, "b": base + 0.001 * rng.standard_normal(20), "c": rng.standard_normal(20)} - ) + block = pd.DataFrame({"a": base, "b": base + 0.001 * rng.standard_normal(20), "c": rng.standard_normal(20)}) clusters = _collinear_clusters(block, threshold=0.95) assert clusters["a"] == clusters["b"] # near-identical columns group together assert clusters["c"] != clusters["a"] # an independent column is its own cluster @@ -331,12 +339,8 @@ def test_discriminator_gate_and_clusters(): products = [f"P{i}" for i in range(9)] rng = np.random.default_rng(3) u = np.linspace(0.0, 1.0, 9) + rng.normal(0, 0.02, 9) - agg = pd.DataFrame( - {"A": 2.0 * u + rng.normal(0, 0.05, 9), "B": rng.normal(0, 1, 9)}, index=products - ) - cov = pd.DataFrame( - {"d1": u, "d2": u + 0.005 * rng.normal(0, 1, 9), "d3": rng.normal(0, 1, 9)}, index=products - ) + agg = pd.DataFrame({"A": 2.0 * u + rng.normal(0, 0.05, 9), "B": rng.normal(0, 1, 9)}, index=products) + cov = pd.DataFrame({"d1": u, "d2": u + 0.005 * rng.normal(0, 1, 9), "d3": rng.normal(0, 1, 9)}, index=products) disc = discriminate_observational(agg, cov, n_components=1, n_permutations=49, random_state=0) # The collinear pair shares a cluster; the noise descriptor does not. @@ -452,10 +456,26 @@ def test_relate_influence_deletions_two_demotes_two_support_spike(): bias = rng.normal(0.0, 0.2) for j, prod in enumerate(LEVERAGE_PRODUCTS): for rep in (1, 2): - rows.append({"panelist_id": pid, "session": 1, "product": prod, "attribute": "A", - "replicate": rep, "score": 5.0 + a_mean[j] + bias + rng.normal(0, 0.15)}) - rows.append({"panelist_id": pid, "session": 1, "product": prod, "attribute": "C", - "replicate": rep, "score": 5.0 + c_mean[j] + bias + rng.normal(0, 0.15)}) + rows.append( + { + "panelist_id": pid, + "session": 1, + "product": prod, + "attribute": "A", + "replicate": rep, + "score": 5.0 + a_mean[j] + bias + rng.normal(0, 0.15), + } + ) + rows.append( + { + "panelist_id": pid, + "session": 1, + "product": prod, + "attribute": "C", + "replicate": rep, + "score": 5.0 + c_mean[j] + bias + rng.normal(0, 0.15), + } + ) panel = pd.DataFrame(rows) cov = pd.DataFrame({"product": LEVERAGE_PRODUCTS, "genuine": genuine, "two_spike": two_spike}) validated = validate_descriptive(panel, cov, mode="observational") @@ -516,9 +536,7 @@ def test_discriminator_demotes_single_support_spike(): }, index=LEVERAGE_PRODUCTS, ) - cov = pd.DataFrame( - {"genuine": genuine, "spike": spike, "noise": rng.normal(0, 1, n)}, index=LEVERAGE_PRODUCTS - ) + cov = pd.DataFrame({"genuine": genuine, "spike": spike, "noise": rng.normal(0, 1, n)}, index=LEVERAGE_PRODUCTS) disc = discriminate_observational(agg, cov, n_components=1, n_permutations=99, random_state=0) desc = pd.DataFrame(disc["descriptors"]) @@ -650,9 +668,7 @@ def test_permutation_null_too_few_descriptors_returns_not_ok(): def test_permutation_null_degenerate_block_returns_not_ok(): """A no-variance descriptor block degrades gracefully instead of crashing.""" products = [f"P{i}" for i in range(8)] - cov = pd.DataFrame( - {"c1": np.ones(8), "c2": np.full(8, 2.0), "c3": np.full(8, 3.0)}, index=products - ) + cov = pd.DataFrame({"c1": np.ones(8), "c2": np.full(8, 2.0), "c3": np.full(8, 3.0)}, index=products) agg = pd.DataFrame({"A": np.arange(8.0)}, index=products) result = permutation_column_null(agg, cov, n_iter=3, min_knockoffs=2, random_state=0) assert not result["ok"] @@ -755,9 +771,7 @@ def test_analyze_correction_align_changes_means_and_reports_mam(): aligned = analyze_descriptive(validated, correction="align", discriminator=False) assert aligned.correction == "align" assert not aligned.mam.scaling.empty - merged = none.product_means.merge( - aligned.product_means, on=["product", "attribute"], suffixes=("_none", "_align") - ) + merged = none.product_means.merge(aligned.product_means, on=["product", "attribute"], suffixes=("_none", "_align")) assert not np.allclose(merged["mean_none"], merged["mean_align"]) @@ -790,6 +804,117 @@ def test_tool_panel_check_missing_columns(): assert any("missing required columns" in e for e in out["errors"]) +def _rcbd_tool_panel(seed: int = 0): + """Build a randomized-complete-block panel: T4 high, aging lowers scores, T2 collapses under HB.""" + rng = np.random.default_rng(seed) + form_effect = {"Control": 0.0, "T1": 0.0, "T2": 0.0, "T3": 0.0, "T4": 3.0} + cond_effect = {"REF": 0.0, "RT": -0.5, "HB": -1.0} + rows = [] + for pid in [f"P{i}" for i in range(7)]: + offset = rng.normal(0.0, 0.5) + for form, fe in form_effect.items(): + for cond, ce in cond_effect.items(): + interaction = -2.0 if (form == "T2" and cond == "HB") else 0.0 + center = 5.0 + fe + ce + interaction + offset + rows.append( + { + "panelist_id": pid, + "attribute": "A", + "formulation": form, + "condition": cond, + "score": center + rng.normal(0.0, 0.35), + } + ) + return pd.DataFrame(rows) + + +def test_tool_compare_products_recovers_interaction(): + import json + + from process_improve.tool_spec import execute_tool_call + + panel = _rcbd_tool_panel().to_dict(orient="records") + out = execute_tool_call( + "sensory_compare_products", + {"panel": panel, "factors": ["formulation", "condition"], "within": "condition", "control": "Control"}, + ) + json.dumps(out) # must be JSON-serialisable for the front end + assert out["ok"] + + anova = {r["source"]: r for r in out["anova"]} + assert anova["formulation"]["p_value"] < 1e-6 + assert anova["formulation:condition"]["p_value"] < 1e-3 + assert anova["Residual"]["p_value"] is None # NaN cleaned to null + + letters = {(r["condition"], r["formulation"]): r["letters"] for r in out["letters"]} + assert letters[("HB", "T2")] != letters[("HB", "Control")] # T2 collapses only under HB + assert letters[("REF", "T2")] == letters[("REF", "Control")] + + dunnett = {(r["condition"], r["level"]): r["reject"] for r in out["dunnett"]} + assert dunnett[("REF", "T4")] is True + assert dunnett[("REF", "T1")] is False + + +def test_tool_compare_products_missing_columns(): + from process_improve.tool_spec import execute_tool_call + + out = execute_tool_call( + "sensory_compare_products", + {"panel": [{"panelist_id": "P1", "score": 5}], "factors": ["formulation"]}, + ) + assert not out["ok"] + assert any("missing required columns" in e for e in out["errors"]) + + +def test_tool_compare_products_pooled_single_factor_no_block(): + # No block, no 'within' stratification, no control: one pooled comparison of + # the single factor, so Dunnett is empty and the stratum column is 'stratum'. + from process_improve.tool_spec import execute_tool_call + + panel = _rcbd_tool_panel().to_dict(orient="records") + out = execute_tool_call( + "sensory_compare_products", + {"panel": panel, "factors": ["formulation"], "block": None}, + ) + assert out["ok"] + assert out["dunnett"] == [] # no control given + assert all("stratum" in row for row in out["letters"]) + assert {row["stratum"] for row in out["letters"]} == {"all"} + # T4 is planted well above the others, so it lands in its own letter group. + t4 = next(r["letters"] for r in out["letters"] if r["formulation"] == "T4") + control = next(r["letters"] for r in out["letters"] if r["formulation"] == "Control") + assert t4 != control + + +def test_tool_compare_products_bad_within_is_reported(): + # A 'within' column that is not in the panel is reported as a missing column + # rather than crashing. + from process_improve.tool_spec import execute_tool_call + + panel = _rcbd_tool_panel().to_dict(orient="records") + out = execute_tool_call( + "sensory_compare_products", + {"panel": panel, "factors": ["formulation", "condition"], "within": "no_such_column"}, + ) + assert not out["ok"] + assert any("no_such_column" in e for e in out["errors"]) + + +def test_tool_compare_products_bad_primary_is_caught(): + # A 'primary' that is not a real column passes the column guard (primary is a + # factor to compare, not a required input column) but makes the underlying + # compare_products raise; the tool catches it and reports {ok: false}. + from process_improve.tool_spec import execute_tool_call + + panel = _rcbd_tool_panel().to_dict(orient="records") + out = execute_tool_call( + "sensory_compare_products", + {"panel": panel, "factors": ["formulation", "condition"], "primary": "no_such_factor"}, + ) + assert not out["ok"] + assert out["errors"] + + def _wide_panel(*, seed: int = 0): """Wide-by-attribute table: rows = assessor x sample x rep, one column per attribute.""" rng = np.random.default_rng(seed)