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.
- eemkit/__init__.py +51 -0
- eemkit/absorbance.py +216 -0
- eemkit/campaigns.py +276 -0
- eemkit/core/__init__.py +1 -0
- eemkit/core/dataset.py +91 -0
- eemkit/core/grid.py +100 -0
- eemkit/core/history.py +78 -0
- eemkit/corrections/__init__.py +44 -0
- eemkit/corrections/_common.py +58 -0
- eemkit/corrections/blank.py +66 -0
- eemkit/corrections/crop.py +33 -0
- eemkit/corrections/inner_filter.py +123 -0
- eemkit/corrections/raman.py +107 -0
- eemkit/corrections/scatter.py +191 -0
- eemkit/corrections/spectral.py +117 -0
- eemkit/errors.py +37 -0
- eemkit/gui/__init__.py +30 -0
- eemkit/gui/__main__.py +5 -0
- eemkit/gui/campaigns_tab.py +275 -0
- eemkit/gui/common.py +395 -0
- eemkit/gui/corrections_tab.py +334 -0
- eemkit/gui/export_tab.py +137 -0
- eemkit/gui/indices_tab.py +228 -0
- eemkit/gui/main_window.py +258 -0
- eemkit/gui/parafac_tab.py +399 -0
- eemkit/gui/project_tab.py +332 -0
- eemkit/gui/state.py +149 -0
- eemkit/gui/theme.py +125 -0
- eemkit/indices.py +256 -0
- eemkit/io/__init__.py +1 -0
- eemkit/io/_cary_csv.py +174 -0
- eemkit/io/cary60.py +120 -0
- eemkit/io/cary_eclipse.py +140 -0
- eemkit/io/discover.py +176 -0
- eemkit/io/loader.py +158 -0
- eemkit/io/pairing.py +115 -0
- eemkit/parafac/__init__.py +28 -0
- eemkit/parafac/_cp.py +125 -0
- eemkit/parafac/diagnostics.py +56 -0
- eemkit/parafac/export.py +54 -0
- eemkit/parafac/model.py +191 -0
- eemkit/parafac/validation.py +111 -0
- eemkit/pipeline.py +560 -0
- eemkit/plot.py +437 -0
- eemkit-0.1.0.dist-info/METADATA +195 -0
- eemkit-0.1.0.dist-info/RECORD +49 -0
- eemkit-0.1.0.dist-info/WHEEL +4 -0
- eemkit-0.1.0.dist-info/entry_points.txt +2 -0
- 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]
|
eemkit/parafac/export.py
ADDED
|
@@ -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
|
eemkit/parafac/model.py
ADDED
|
@@ -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
|