eemkit 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. eemkit/__init__.py +51 -0
  2. eemkit/absorbance.py +216 -0
  3. eemkit/campaigns.py +276 -0
  4. eemkit/core/__init__.py +1 -0
  5. eemkit/core/dataset.py +91 -0
  6. eemkit/core/grid.py +100 -0
  7. eemkit/core/history.py +78 -0
  8. eemkit/corrections/__init__.py +44 -0
  9. eemkit/corrections/_common.py +58 -0
  10. eemkit/corrections/blank.py +66 -0
  11. eemkit/corrections/crop.py +33 -0
  12. eemkit/corrections/inner_filter.py +123 -0
  13. eemkit/corrections/raman.py +107 -0
  14. eemkit/corrections/scatter.py +191 -0
  15. eemkit/corrections/spectral.py +117 -0
  16. eemkit/errors.py +37 -0
  17. eemkit/gui/__init__.py +30 -0
  18. eemkit/gui/__main__.py +5 -0
  19. eemkit/gui/campaigns_tab.py +275 -0
  20. eemkit/gui/common.py +395 -0
  21. eemkit/gui/corrections_tab.py +334 -0
  22. eemkit/gui/export_tab.py +137 -0
  23. eemkit/gui/indices_tab.py +228 -0
  24. eemkit/gui/main_window.py +258 -0
  25. eemkit/gui/parafac_tab.py +399 -0
  26. eemkit/gui/project_tab.py +332 -0
  27. eemkit/gui/state.py +149 -0
  28. eemkit/gui/theme.py +125 -0
  29. eemkit/indices.py +256 -0
  30. eemkit/io/__init__.py +1 -0
  31. eemkit/io/_cary_csv.py +174 -0
  32. eemkit/io/cary60.py +120 -0
  33. eemkit/io/cary_eclipse.py +140 -0
  34. eemkit/io/discover.py +176 -0
  35. eemkit/io/loader.py +158 -0
  36. eemkit/io/pairing.py +115 -0
  37. eemkit/parafac/__init__.py +28 -0
  38. eemkit/parafac/_cp.py +125 -0
  39. eemkit/parafac/diagnostics.py +56 -0
  40. eemkit/parafac/export.py +54 -0
  41. eemkit/parafac/model.py +191 -0
  42. eemkit/parafac/validation.py +111 -0
  43. eemkit/pipeline.py +560 -0
  44. eemkit/plot.py +437 -0
  45. eemkit-0.1.0.dist-info/METADATA +195 -0
  46. eemkit-0.1.0.dist-info/RECORD +49 -0
  47. eemkit-0.1.0.dist-info/WHEEL +4 -0
  48. eemkit-0.1.0.dist-info/entry_points.txt +2 -0
  49. eemkit-0.1.0.dist-info/licenses/LICENSE +674 -0
eemkit/parafac/_cp.py ADDED
@@ -0,0 +1,125 @@
1
+ """Non-negative CP/PARAFAC of a 3-way array with missing values (numpy only).
2
+
3
+ Algorithm: alternating updates of the three factor matrices with HALS
4
+ (hierarchical alternating least squares; Cichocki & Phan 2009, IEICE Trans.
5
+ Fundamentals E92-A:708-721), which solves each column's non-negative least
6
+ squares problem exactly. Missing values are handled by expectation-
7
+ maximisation: before each sweep they are replaced by the current model
8
+ (Tomasi & Bro 2005, Chemometr. Intell. Lab. Syst. 75:163-180), the approach of
9
+ the N-way toolbox used by drEEM.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ import numpy as np
17
+
18
+ EPS = 1e-16 # floor for non-negative entries; keeps HALS columns from dying
19
+
20
+
21
+ @dataclass
22
+ class CPResult:
23
+ A: np.ndarray
24
+ B: np.ndarray
25
+ C: np.ndarray
26
+ sse: float
27
+ n_iter: int
28
+ converged: bool
29
+ sse_trace: list[float] = field(default_factory=list)
30
+
31
+
32
+ def khatri_rao(P: np.ndarray, Q: np.ndarray) -> np.ndarray:
33
+ """Column-wise Kronecker product; row index p * len(Q) + q."""
34
+ return (P[:, None, :] * Q[None, :, :]).reshape(-1, P.shape[1])
35
+
36
+
37
+ def unfold(X: np.ndarray, mode: int) -> np.ndarray:
38
+ return np.moveaxis(X, mode, 0).reshape(X.shape[mode], -1)
39
+
40
+
41
+ def reconstruct(A: np.ndarray, B: np.ndarray, C: np.ndarray) -> np.ndarray:
42
+ return (khatri_rao(A, B) @ C.T).reshape(A.shape[0], B.shape[0], C.shape[0])
43
+
44
+
45
+ def _hals_update(M: np.ndarray, R: np.ndarray, G: np.ndarray) -> None:
46
+ """In-place HALS sweep over the columns of M for min ||X_(n) - M P^T||, M >= 0."""
47
+ for f in range(M.shape[1]):
48
+ if G[f, f] <= 0:
49
+ continue
50
+ M[:, f] = np.maximum(EPS, M[:, f] + (R[:, f] - M @ G[:, f]) / G[f, f])
51
+
52
+
53
+ def _balance(A: np.ndarray, B: np.ndarray, C: np.ndarray) -> None:
54
+ """Unit-norm columns for B and C; the scale goes into A."""
55
+ nb = np.linalg.norm(B, axis=0)
56
+ nc = np.linalg.norm(C, axis=0)
57
+ nb[nb == 0] = 1
58
+ nc[nc == 0] = 1
59
+ B /= nb
60
+ C /= nc
61
+ A *= nb * nc
62
+
63
+
64
+ def nn_parafac(
65
+ X: np.ndarray,
66
+ rank: int,
67
+ *,
68
+ init: tuple[np.ndarray, np.ndarray, np.ndarray] | None = None,
69
+ random_state: int | np.random.Generator | None = None,
70
+ tol: float = 1e-8,
71
+ max_iter: int = 5000,
72
+ ) -> CPResult:
73
+ """Fit X (I x J x K, NaN = missing) ~ sum_f a_f o b_f o c_f with A, B, C >= 0.
74
+
75
+ Stops when the relative decrease of the residual sum of squares over the
76
+ observed entries falls below ``tol``, or after ``max_iter`` sweeps.
77
+ """
78
+ X = np.asarray(X, dtype=float)
79
+ if X.ndim != 3:
80
+ raise ValueError("X must be 3-way")
81
+ observed = np.isfinite(X)
82
+ if not observed.any():
83
+ raise ValueError("X has no observed values")
84
+ rng = np.random.default_rng(random_state)
85
+ n_i, n_j, n_k = X.shape
86
+ if init is None:
87
+ A = rng.random((n_i, rank))
88
+ B = rng.random((n_j, rank))
89
+ C = rng.random((n_k, rank))
90
+ else:
91
+ A, B, C = (np.array(m, dtype=float, copy=True) for m in init)
92
+ A, B, C = (np.maximum(m, EPS) for m in (A, B, C))
93
+ _balance(A, B, C)
94
+
95
+ has_missing = not observed.all()
96
+ # least-squares scale of the random start, so missing values start near the data
97
+ model = reconstruct(A, B, C)
98
+ s = np.sum(X[observed] * model[observed]) / max(np.sum(model[observed] ** 2), EPS)
99
+ A *= max(s, EPS)
100
+ model *= max(s, EPS)
101
+ Xf = X
102
+
103
+ trace: list[float] = []
104
+ prev = np.inf
105
+ converged = False
106
+ n_iter = 0
107
+ for n_iter in range(1, max_iter + 1): # noqa: B007 (n_iter is reported after the loop)
108
+ if has_missing:
109
+ Xf = np.where(observed, X, model)
110
+ # MTTKRPs as BLAS products without unfolding copies: T = X x3 C (I x J x F)
111
+ T = Xf @ C
112
+ _hals_update(A, np.einsum("ijf,jf->if", T, B), (B.T @ B) * (C.T @ C))
113
+ _hals_update(B, np.einsum("ijf,if->jf", T, A), (A.T @ A) * (C.T @ C))
114
+ _hals_update(C, Xf.reshape(n_i * n_j, n_k).T @ khatri_rao(A, B),
115
+ (A.T @ A) * (B.T @ B))
116
+ _balance(A, B, C)
117
+
118
+ model = reconstruct(A, B, C)
119
+ sse = float(np.sum((X - model)[observed] ** 2))
120
+ trace.append(sse)
121
+ if np.isfinite(prev) and abs(prev - sse) <= tol * max(prev, EPS):
122
+ converged = True
123
+ break
124
+ prev = sse
125
+ return CPResult(A, B, C, trace[-1], n_iter, converged, trace)
@@ -0,0 +1,56 @@
1
+ """Model diagnostics: core consistency, leverage, congruence and component matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ from scipy.optimize import linear_sum_assignment
7
+
8
+
9
+ def core_consistency(X: np.ndarray, A: np.ndarray, B: np.ndarray, C: np.ndarray) -> float:
10
+ """CORCONDIA in percent (Bro & Kiers 2003, J. Chemometrics 17:274-286).
11
+
12
+ The least-squares Tucker3 core for the given loadings is
13
+ ``X x1 A+ x2 B+ x3 C+`` (``+`` = pseudo-inverse); with ``T`` the
14
+ superdiagonal identity core, ``CC = 100 (1 - ||G - T||^2 / F)``.
15
+ ``X`` must be complete: impute missing values (e.g. with the model) first.
16
+ """
17
+ if not np.isfinite(X).all():
18
+ raise ValueError("core_consistency needs a complete array; impute missing values")
19
+ F = A.shape[1]
20
+ G = np.tensordot(np.linalg.pinv(A), X, axes=(1, 0)) # F x J x K
21
+ G = np.tensordot(G, np.linalg.pinv(B), axes=(1, 1)) # F x K x F
22
+ G = np.tensordot(G, np.linalg.pinv(C), axes=(1, 1)) # F x F x F
23
+ T = np.zeros((F, F, F))
24
+ T[np.arange(F), np.arange(F), np.arange(F)] = 1.0
25
+ return float(100.0 * (1.0 - np.sum((G - T) ** 2) / F))
26
+
27
+
28
+ def leverage(M: np.ndarray) -> np.ndarray:
29
+ """Leverage of each row of a loading matrix: diag(M (M^T M)^-1 M^T)."""
30
+ return np.einsum("if,fg,ig->i", M, np.linalg.pinv(M.T @ M), M)
31
+
32
+
33
+ def tucker_congruence(P: np.ndarray, Q: np.ndarray) -> np.ndarray:
34
+ """Tucker's congruence coefficients between the columns of P and Q (F_P x F_Q)."""
35
+ P = np.asarray(P, dtype=float)
36
+ Q = np.asarray(Q, dtype=float)
37
+ num = P.T @ Q
38
+ den = np.outer(np.linalg.norm(P, axis=0), np.linalg.norm(Q, axis=0))
39
+ with np.errstate(invalid="ignore", divide="ignore"):
40
+ return num / den
41
+
42
+
43
+ def match_components(
44
+ ex1: np.ndarray, em1: np.ndarray, ex2: np.ndarray, em2: np.ndarray
45
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
46
+ """Pair components of two models by maximising the product of ex and em congruence.
47
+
48
+ Returns ``(order2, tcc_ex, tcc_em)``: model 2's component ``order2[f]``
49
+ matches model 1's component ``f``, with the congruences of each pair.
50
+ """
51
+ tex = tucker_congruence(ex1, ex2)
52
+ tem = tucker_congruence(em1, em2)
53
+ rows, cols = linear_sum_assignment(-(tex * tem))
54
+ order = cols[np.argsort(rows)]
55
+ f = np.arange(len(order))
56
+ return order, tex[f, order], tem[f, order]
@@ -0,0 +1,54 @@
1
+ """Export of PARAFAC loadings in the OpenFluor text format.
2
+
3
+ OpenFluor (openfluor.org; Murphy et al. 2014, Anal. Methods 6:658-661)
4
+ accepts a tab-separated file: '#' comment lines, a block of ``key<TAB>value``
5
+ metadata, then one line per wavelength, ``Ex`` or ``Em`` followed by the
6
+ wavelength and the loading of each component. This follows the layout written
7
+ by drEEM's ``openfluor`` function; check the file against the current
8
+ OpenFluor upload template before submitting.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from datetime import date
14
+ from pathlib import Path
15
+
16
+ import xarray as xr
17
+
18
+ METADATA_KEYS = ("name", "creator", "email", "doi", "reference", "unit", "toolbox", "date",
19
+ "fluorometer", "nSample", "constraints", "validation", "methods",
20
+ "preprocess", "sources", "ecozones", "description")
21
+
22
+
23
+ def to_openfluor(model: xr.Dataset, path: str | Path, **metadata: str) -> Path:
24
+ """Write ``model``'s excitation and emission loadings for OpenFluor.
25
+
26
+ Loadings are scaled to a maximum of 1 per component (OpenFluor compares
27
+ shapes). Unknown metadata are written as '?'; pass e.g. ``name=``,
28
+ ``creator=``, ``doi=``, ``description=``. Fields derived from the model
29
+ (unit, toolbox, date, nSample, constraints) are filled in unless given.
30
+ """
31
+ from eemkit import __version__
32
+
33
+ unknown = set(metadata) - set(METADATA_KEYS)
34
+ if unknown:
35
+ raise ValueError(f"unknown OpenFluor metadata {sorted(unknown)}; use {METADATA_KEYS}")
36
+ meta = {k: "?" for k in METADATA_KEYS}
37
+ meta.update(unit=str(model["scores"].attrs.get("units", "?")) or "?",
38
+ toolbox=f"eemkit {__version__}", date=date.today().isoformat(),
39
+ nSample=str(model.sizes["sample"]), constraints="non-negativity")
40
+ meta.update({k: str(v) for k, v in metadata.items()})
41
+
42
+ ex = model["ex_loadings"] / model["ex_loadings"].max("ex")
43
+ em = model["em_loadings"] / model["em_loadings"].max("em")
44
+ lines = ["#", "# Fluorescence Model", "#"]
45
+ lines += [f"{k}\t{meta[k]}" for k in METADATA_KEYS]
46
+ lines += ["#", "# Excitation/Emission (Ex, Em), wavelength [nm], component[n] [intensity]",
47
+ "#"]
48
+ for label, da, dim in (("Ex", ex, "ex"), ("Em", em, "em")):
49
+ for w in da[dim].values:
50
+ vals = da.sel({dim: w}).values
51
+ lines.append("\t".join([label, f"{w:g}"] + [f"{v:.6g}" for v in vals]))
52
+ path = Path(path)
53
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
54
+ return path
@@ -0,0 +1,191 @@
1
+ """Fitting PARAFAC models to an EEM dataset and packaging the result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import warnings
7
+
8
+ import numpy as np
9
+ import xarray as xr
10
+
11
+ from eemkit.core.dataset import SAMPLE_DIM
12
+ from eemkit.core.history import HISTORY_ATTR, get_history, make_entry
13
+ from eemkit.errors import EEMKitWarning
14
+ from eemkit.parafac._cp import nn_parafac, reconstruct
15
+ from eemkit.parafac.diagnostics import core_consistency, leverage
16
+
17
+ NORMALIZATIONS = ("norm", None)
18
+
19
+ #: two runs whose SSE differs by less than this fraction are considered the same minimum
20
+ SAME_MINIMUM_RTOL = 1e-3
21
+
22
+
23
+ def _sample_scale(X: np.ndarray, normalization: str | None) -> np.ndarray:
24
+ if normalization is None:
25
+ return np.ones(X.shape[0])
26
+ if normalization == "norm":
27
+ s = np.sqrt(np.nansum(X ** 2, axis=(1, 2)))
28
+ if np.any(s == 0):
29
+ raise ValueError("a sample is all zero/NaN and cannot be normalised")
30
+ return s
31
+ raise ValueError(f"normalization must be one of {NORMALIZATIONS}")
32
+
33
+
34
+ def fit_parafac(
35
+ ds: xr.Dataset,
36
+ rank: int,
37
+ *,
38
+ n_init: int = 10,
39
+ normalization: str | None = "norm",
40
+ tol: float = 1e-8,
41
+ max_iter: int = 5000,
42
+ random_state: int = 0,
43
+ variable: str = "intensity",
44
+ ) -> xr.Dataset:
45
+ """Non-negative PARAFAC of ``ds[variable]`` (sample, ex, em), best of ``n_init`` starts.
46
+
47
+ Parameters
48
+ ----------
49
+ rank
50
+ Number of components.
51
+ n_init
52
+ Random starts (seeds ``random_state``, ``random_state + 1``, ...). The
53
+ lowest-SSE fit is kept; all SSEs are stored to judge whether the
54
+ solution is reproducible.
55
+ normalization
56
+ ``"norm"``: each sample is divided by its Frobenius norm before
57
+ fitting and the scores are scaled back afterwards (Murphy et al. 2013);
58
+ ``None``: no scaling.
59
+ tol, max_iter
60
+ Convergence: relative SSE change below ``tol``.
61
+
62
+ Returns
63
+ -------
64
+ xarray.Dataset
65
+ ``scores(sample, component)`` in the units of the data (after undoing
66
+ the normalisation), ``ex_loadings(ex, component)`` and
67
+ ``em_loadings(em, component)`` with unit Euclidean norm, ``fmax``
68
+ (sample, component) = maximum fluorescence of each component in each
69
+ sample, leverages for every mode, per-sample residual SSE, and in
70
+ ``attrs`` the fit statistics (SSE, explained variance, core
71
+ consistency, iterations, all starts). Components are sorted by
72
+ decreasing contribution to the modelled sum of squares.
73
+ """
74
+ da = ds[variable].transpose(SAMPLE_DIM, "ex", "em")
75
+ X = da.values.astype(float)
76
+ scale = _sample_scale(X, normalization)
77
+ Xn = X / scale[:, None, None]
78
+
79
+ fits = []
80
+ for k in range(n_init):
81
+ fits.append(nn_parafac(Xn, rank, random_state=random_state + k, tol=tol,
82
+ max_iter=max_iter))
83
+ sses = np.array([f.sse for f in fits])
84
+ best = fits[int(np.argmin(sses))]
85
+ if not best.converged:
86
+ warnings.warn(f"rank {rank}: best start did not converge in {max_iter} iterations",
87
+ EEMKitWarning, stacklevel=2)
88
+
89
+ A, B, C = best.A.copy(), best.B.copy(), best.C.copy()
90
+ contrib = np.array([np.sum(np.einsum("i,j,k->ijk", A[:, f], B[:, f], C[:, f]) ** 2)
91
+ for f in range(rank)])
92
+ order = np.argsort(-contrib)
93
+ A, B, C = A[:, order], B[:, order], C[:, order]
94
+
95
+ observed = np.isfinite(Xn)
96
+ model_n = reconstruct(A, B, C)
97
+ filled = np.where(observed, Xn, model_n)
98
+ cc = core_consistency(filled, A, B, C) if rank > 1 else 100.0
99
+ resid_n = np.where(observed, Xn - model_n, np.nan)
100
+ sse_n = float(np.nansum(resid_n ** 2))
101
+ explained = 100.0 * (1.0 - sse_n / float(np.nansum(Xn ** 2)))
102
+
103
+ scores = A * scale[:, None]
104
+ resid = resid_n * scale[:, None, None]
105
+ comp = np.arange(1, rank + 1)
106
+ sample_coords = {c: ds[c] for c in ds.coords
107
+ if c != SAMPLE_DIM and ds[c].dims == (SAMPLE_DIM,)}
108
+ out = xr.Dataset(
109
+ {
110
+ "scores": ((SAMPLE_DIM, "component"), scores,
111
+ {"units": da.attrs.get("units", ""),
112
+ "long_name": "component scores (normalisation undone)"}),
113
+ "ex_loadings": (("ex", "component"), B,
114
+ {"long_name": "excitation loadings, unit norm"}),
115
+ "em_loadings": (("em", "component"), C,
116
+ {"long_name": "emission loadings, unit norm"}),
117
+ "fmax": ((SAMPLE_DIM, "component"), scores * B.max(axis=0) * C.max(axis=0),
118
+ {"units": da.attrs.get("units", ""),
119
+ "long_name": "maximum fluorescence of each component"}),
120
+ "leverage_sample": ((SAMPLE_DIM,), leverage(A)),
121
+ "leverage_ex": (("ex",), leverage(B)),
122
+ "leverage_em": (("em",), leverage(C)),
123
+ "sse_sample": ((SAMPLE_DIM,), np.nansum(resid ** 2, axis=(1, 2))),
124
+ "sse_sample_normalized": ((SAMPLE_DIM,), np.nansum(resid_n ** 2, axis=(1, 2))),
125
+ "init_sse": (("init",), sses),
126
+ "init_n_iter": (("init",), [f.n_iter for f in fits]),
127
+ "init_converged": (("init",), [f.converged for f in fits]),
128
+ },
129
+ coords={SAMPLE_DIM: da[SAMPLE_DIM].values, "ex": da["ex"].values,
130
+ "em": da["em"].values, "component": comp, "init": np.arange(n_init),
131
+ **sample_coords},
132
+ )
133
+ n_same = int(np.sum(sses <= sses.min() * (1 + SAME_MINIMUM_RTOL)))
134
+ out.attrs = {
135
+ "rank": rank,
136
+ "sse_normalized": sse_n,
137
+ "explained_variance_pct": explained,
138
+ "core_consistency_pct": cc,
139
+ "n_iter": best.n_iter,
140
+ "converged": int(best.converged),
141
+ "n_init": n_init,
142
+ "n_init_at_best_minimum": n_same,
143
+ "normalization": str(normalization),
144
+ "tol": tol,
145
+ "max_iter": max_iter,
146
+ "random_state": random_state,
147
+ "algorithm": "non-negative ALS (HALS updates, EM imputation of missing values)",
148
+ "fraction_missing": float(1 - observed.mean()),
149
+ "spectral_correction": ds.attrs.get("spectral_correction", "none"),
150
+ }
151
+ history = get_history(ds) + [make_entry(
152
+ "fit_parafac", rank=rank, n_init=n_init, normalization=normalization, tol=tol,
153
+ max_iter=max_iter, random_state=random_state, variable=variable)]
154
+ out.attrs[HISTORY_ATTR] = json.dumps(history)
155
+ return out
156
+
157
+
158
+ def model_eem(model: xr.Dataset) -> xr.DataArray:
159
+ """Modelled EEMs (sample, ex, em) in data units."""
160
+ vals = reconstruct(model["scores"].values, model["ex_loadings"].values,
161
+ model["em_loadings"].values)
162
+ return xr.DataArray(vals, dims=(SAMPLE_DIM, "ex", "em"),
163
+ coords={SAMPLE_DIM: model[SAMPLE_DIM].values, "ex": model["ex"].values,
164
+ "em": model["em"].values})
165
+
166
+
167
+ def residuals(model: xr.Dataset, ds: xr.Dataset, variable: str = "intensity") -> xr.DataArray:
168
+ """Data minus model (NaN where data are missing)."""
169
+ data = ds[variable].transpose(SAMPLE_DIM, "ex", "em").sel(
170
+ {SAMPLE_DIM: model[SAMPLE_DIM].values})
171
+ return (data - model_eem(model)).rename("residual")
172
+
173
+
174
+ def fit_ranks(ds: xr.Dataset, ranks, **kwargs) -> dict[int, xr.Dataset]:
175
+ """:func:`fit_parafac` for each rank."""
176
+ return {r: fit_parafac(ds, r, **kwargs) for r in ranks}
177
+
178
+
179
+ def summary_table(models: dict[int, xr.Dataset]):
180
+ """One row per rank with the fit statistics."""
181
+ import pandas as pd
182
+
183
+ rows = []
184
+ for r, m in sorted(models.items()):
185
+ a = m.attrs
186
+ rows.append({"rank": r, "explained_pct": a["explained_variance_pct"],
187
+ "core_consistency_pct": a["core_consistency_pct"],
188
+ "sse_normalized": a["sse_normalized"], "n_iter": a["n_iter"],
189
+ "converged": bool(a["converged"]),
190
+ "starts_at_best": f"{a['n_init_at_best_minimum']}/{a['n_init']}"})
191
+ return pd.DataFrame(rows).set_index("rank")
@@ -0,0 +1,111 @@
1
+ """Split-half validation (Murphy et al. 2013, Anal. Methods 5:6557-6566).
2
+
3
+ The 'S4C6T3' design: samples are divided into four groups; pairs of groups
4
+ form six half-datasets, which make three independent comparisons
5
+ (AB vs CD, AC vs BD, AD vs BC). A model is validated when, in every
6
+ comparison, each component of one half matches a component of the other half
7
+ with Tucker congruence above a threshold in both excitation and emission;
8
+ 0.95 is commonly used (Lorenzo-Seva & ten Berge 2006, Methodology 2:57-64).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+
15
+ import numpy as np
16
+ import pandas as pd
17
+ import xarray as xr
18
+
19
+ from eemkit.core.dataset import SAMPLE_DIM
20
+ from eemkit.parafac.diagnostics import match_components
21
+ from eemkit.parafac.model import fit_parafac
22
+
23
+ PAIRS = (((0, 1), (2, 3)), ((0, 2), (1, 3)), ((0, 3), (1, 2)))
24
+ LETTERS = "ABCD"
25
+
26
+
27
+ def split_groups(ds: xr.Dataset, *, random_state: int = 0,
28
+ stratify: str | None = None) -> np.ndarray:
29
+ """Assign each sample to one of 4 groups (0-3).
30
+
31
+ Samples are shuffled; with ``stratify`` (a sample coordinate such as
32
+ 'campaign') they are dealt round-robin within each stratum, so every group
33
+ holds a similar mix.
34
+ """
35
+ rng = np.random.default_rng(random_state)
36
+ n = ds.sizes[SAMPLE_DIM]
37
+ groups = np.empty(n, dtype=int)
38
+ strata = ds[stratify].values if stratify else np.zeros(n)
39
+ offset = 0
40
+ for s in pd.unique(strata):
41
+ idx = np.flatnonzero(strata == s)
42
+ rng.shuffle(idx)
43
+ groups[idx] = (np.arange(idx.size) + offset) % 4
44
+ offset += idx.size
45
+ return groups
46
+
47
+
48
+ def split_half(
49
+ ds: xr.Dataset,
50
+ rank: int,
51
+ *,
52
+ groups: np.ndarray | None = None,
53
+ stratify: str | None = None,
54
+ split_random_state: int = 0,
55
+ threshold: float = 0.95,
56
+ **fit_kwargs,
57
+ ) -> xr.Dataset:
58
+ """Run the S4C6T3 split-half validation for one rank.
59
+
60
+ ``split_random_state`` seeds the division into groups; ``fit_kwargs``
61
+ (including ``random_state``) go to :func:`fit_parafac` for every half.
62
+ Returns a dataset with ``tcc_ex`` and ``tcc_em`` (test, component), the
63
+ half-model loadings (half, ex|em, component, aligned to the first half of
64
+ each test), ``groups(sample)`` and attrs ``validated`` and ``min_tcc``.
65
+ """
66
+ if groups is None:
67
+ groups = split_groups(ds, random_state=split_random_state, stratify=stratify)
68
+ halves = {}
69
+ for g1, g2 in PAIRS:
70
+ for g in (g1, g2):
71
+ key = "".join(LETTERS[i] for i in g)
72
+ if key not in halves:
73
+ sel = np.isin(groups, g)
74
+ halves[key] = fit_parafac(ds.isel({SAMPLE_DIM: sel}), rank, **fit_kwargs)
75
+
76
+ tex, tem, names, ex_l, em_l, half_names = [], [], [], [], [], []
77
+ for g1, g2 in PAIRS:
78
+ k1 = "".join(LETTERS[i] for i in g1)
79
+ k2 = "".join(LETTERS[i] for i in g2)
80
+ m1, m2 = halves[k1], halves[k2]
81
+ order, te, tm = match_components(m1.ex_loadings.values, m1.em_loadings.values,
82
+ m2.ex_loadings.values, m2.em_loadings.values)
83
+ tex.append(te)
84
+ tem.append(tm)
85
+ names.append(f"{k1} vs {k2}")
86
+ for k, m, o in ((k1, m1, np.arange(rank)), (k2, m2, order)):
87
+ half_names.append(k)
88
+ ex_l.append(m.ex_loadings.values[:, o])
89
+ em_l.append(m.em_loadings.values[:, o])
90
+
91
+ tex, tem = np.array(tex), np.array(tem)
92
+ min_tcc = float(np.minimum(tex, tem).min())
93
+ out = xr.Dataset(
94
+ {
95
+ "tcc_ex": (("test", "component"), tex),
96
+ "tcc_em": (("test", "component"), tem),
97
+ "half_ex_loadings": (("half", "ex", "component"), np.array(ex_l)),
98
+ "half_em_loadings": (("half", "em", "component"), np.array(em_l)),
99
+ "groups": ((SAMPLE_DIM,), groups),
100
+ },
101
+ coords={"test": names, "component": np.arange(1, rank + 1),
102
+ "half": [f"test{i // 2 + 1}:{n}" for i, n in enumerate(half_names)],
103
+ "ex": ds["ex"].values, "em": ds["em"].values,
104
+ SAMPLE_DIM: ds[SAMPLE_DIM].values},
105
+ )
106
+ out.attrs = {"rank": rank, "threshold": threshold, "min_tcc": min_tcc,
107
+ "validated": int(min_tcc >= threshold), "design": "S4C6T3",
108
+ "stratify": str(stratify), "split_random_state": split_random_state,
109
+ "half_explained_pct": json.dumps(
110
+ {k: round(m.attrs["explained_variance_pct"], 3) for k, m in halves.items()})}
111
+ return out