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
@@ -0,0 +1,399 @@
1
+ """Tab 4: exploratory PARAFAC models, outliers, and the final model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ from PySide6.QtCore import Qt
7
+ from PySide6.QtWidgets import (
8
+ QAbstractItemView,
9
+ QComboBox,
10
+ QFormLayout,
11
+ QGroupBox,
12
+ QLabel,
13
+ QLineEdit,
14
+ QListWidget,
15
+ QMessageBox,
16
+ QPushButton,
17
+ QScrollArea,
18
+ QSpinBox,
19
+ QSplitter,
20
+ QTableView,
21
+ QTabWidget,
22
+ QVBoxLayout,
23
+ QWidget,
24
+ )
25
+
26
+ from eemkit import pipeline, plot
27
+ from eemkit.gui.common import DataFrameModel, PlotPanel, TaskBar, note, pair, primary, spin
28
+ from eemkit.gui.state import Project
29
+
30
+
31
+ def _int_spin(value: int, lo: int, hi: int) -> QSpinBox:
32
+ s = QSpinBox()
33
+ s.setRange(lo, hi)
34
+ s.setValue(int(value))
35
+ return s
36
+
37
+
38
+ def _sci(value: float) -> QLineEdit:
39
+ e = QLineEdit(f"{float(value):g}")
40
+ e.setMaximumWidth(90)
41
+ return e
42
+
43
+
44
+ def _float(edit: QLineEdit) -> float:
45
+ return float(edit.text().strip().replace(",", "."))
46
+
47
+
48
+ class ParafacTab(QWidget):
49
+ def __init__(self, project: Project, tasks: TaskBar, parent=None):
50
+ super().__init__(parent)
51
+ self.project, self.tasks = project, tasks
52
+ p = pipeline.DEFAULTS["parafac"]
53
+
54
+ self.r_from = _int_spin(2, 1, 20)
55
+ self.r_to = _int_spin(7, 1, 20)
56
+ self.n_init = _int_spin(p["n_init"], 1, 500)
57
+ self.norm = QComboBox()
58
+ self.norm.addItems(["norm", "none"])
59
+ self.tol = _sci(p["tol"])
60
+ self.max_iter = _int_spin(p["max_iter"], 10, 1_000_000)
61
+ self.seed = _int_spin(p["random_state"], 0, 2**31 - 1)
62
+ g_exp = QGroupBox("Exploratory models (Murphy et al. 2013)")
63
+ f = QFormLayout(g_exp)
64
+ f.addRow("Components", pair(self.r_from, QLabel("to"), self.r_to))
65
+ f.addRow("Random starts", self.n_init)
66
+ f.addRow("Sample scaling", self.norm)
67
+ f.addRow("Convergence (rel. SSE change)", self.tol)
68
+ f.addRow("Max iterations", self.max_iter)
69
+ f.addRow("Random seed", self.seed)
70
+
71
+ sh = p["split_half"]
72
+ self.sh_init = _int_spin(sh["n_init"], 1, 500)
73
+ self.sh_strat = QLineEdit()
74
+ self.sh_strat.setPlaceholderText("e.g. river, campaign")
75
+ self.sh_thr = spin(sh["threshold"], 0.5, 1.0, 0.01, 2)
76
+ self.sh_seed = _int_spin(sh["random_state"], 0, 2**31 - 1)
77
+ g_sh = QGroupBox("Split-half validation (S4C6T3)")
78
+ f = QFormLayout(g_sh)
79
+ f.addRow("Random starts per half", self.sh_init)
80
+ f.addRow("Balance halves by", self.sh_strat)
81
+ f.addRow("Tucker congruence threshold", self.sh_thr)
82
+ f.addRow("Split seed", self.sh_seed)
83
+
84
+ self.excluded = QListWidget()
85
+ self.excluded.setMaximumHeight(110)
86
+ self.b_unexclude = QPushButton("Put back selected")
87
+ self.b_unexclude.clicked.connect(self.unexclude)
88
+ g_exc = QGroupBox("Samples left out of the model")
89
+ v = QVBoxLayout(g_exc)
90
+ v.addWidget(self.excluded)
91
+ v.addWidget(self.b_unexclude)
92
+ v.addWidget(note("Add samples from the Outliers view. Record why in your notes: "
93
+ "the list is saved in the project file."))
94
+
95
+ self.b_explore = primary(QPushButton("Run exploratory models"))
96
+ self.b_explore.clicked.connect(self.explore)
97
+
98
+ fin = p["final"]
99
+ self.f_rank = _int_spin(3, 1, 20)
100
+ self.f_init = _int_spin(fin["n_init"], 1, 500)
101
+ self.f_tol = _sci(fin["tol"])
102
+ self.f_iter = _int_spin(fin["max_iter"], 10, 1_000_000)
103
+ g_fin = QGroupBox("Final model")
104
+ f = QFormLayout(g_fin)
105
+ f.addRow("Components", self.f_rank)
106
+ f.addRow("Random starts", self.f_init)
107
+ f.addRow("Convergence", self.f_tol)
108
+ f.addRow("Max iterations", self.f_iter)
109
+ self.b_final = primary(QPushButton("Fit final model"))
110
+ self.b_final.clicked.connect(self.fit_final)
111
+ f.addRow(self.b_final)
112
+
113
+ form = QWidget()
114
+ fl = QVBoxLayout(form)
115
+ for w in (g_exp, g_sh, g_exc, self.b_explore, g_fin):
116
+ fl.addWidget(w)
117
+ fl.addStretch(1)
118
+ scroll = QScrollArea()
119
+ scroll.setWidget(form)
120
+ scroll.setWidgetResizable(True)
121
+ scroll.setMinimumWidth(360)
122
+
123
+ # results
124
+ self.summary_model = DataFrameModel()
125
+ summary_view = QTableView()
126
+ summary_view.setModel(self.summary_model)
127
+ self.diag_plot = PlotPanel()
128
+ page_summary = QWidget()
129
+ v = QVBoxLayout(page_summary)
130
+ v.addWidget(summary_view, 1)
131
+ v.addWidget(self.diag_plot, 2)
132
+
133
+ self.load_rank = QComboBox()
134
+ self.load_rank.currentIndexChanged.connect(self.draw_loadings)
135
+ self.load_plot = PlotPanel()
136
+ page_load = QWidget()
137
+ v = QVBoxLayout(page_load)
138
+ v.addWidget(pair(QLabel("Model"), self.load_rank))
139
+ v.addWidget(self.load_plot, 1)
140
+
141
+ self.out_rank = QComboBox()
142
+ self.out_rank.currentIndexChanged.connect(self.draw_outliers)
143
+ self.out_by = QComboBox()
144
+ self.out_by.currentIndexChanged.connect(self.draw_outliers)
145
+ self.out_plot = PlotPanel()
146
+ self.out_model = DataFrameModel(float_format="{:.4f}")
147
+ self.out_view = QTableView()
148
+ self.out_view.setModel(self.out_model)
149
+ self.out_view.setSelectionBehavior(QAbstractItemView.SelectRows)
150
+ b_exclude = QPushButton("Leave selected samples out")
151
+ b_exclude.clicked.connect(self.exclude_selected)
152
+ page_out = QWidget()
153
+ v = QVBoxLayout(page_out)
154
+ v.addWidget(pair(QLabel("Model"), self.out_rank, QLabel("colour by"), self.out_by))
155
+ h = QSplitter()
156
+ h.addWidget(self.out_plot)
157
+ side = QWidget()
158
+ sv = QVBoxLayout(side)
159
+ sv.setContentsMargins(0, 0, 0, 0)
160
+ sv.addWidget(self.out_view, 1)
161
+ sv.addWidget(b_exclude)
162
+ h.addWidget(side)
163
+ v.addWidget(h, 1)
164
+
165
+ self.final_plot = PlotPanel()
166
+ self.final_text = QLabel("")
167
+ self.final_text.setTextInteractionFlags(Qt.TextSelectableByMouse)
168
+ page_final = QWidget()
169
+ v = QVBoxLayout(page_final)
170
+ v.addWidget(self.final_text)
171
+ v.addWidget(self.final_plot, 1)
172
+
173
+ self.results = QTabWidget()
174
+ self.results.addTab(page_summary, "Number of components")
175
+ self.results.addTab(page_load, "Components by model")
176
+ self.results.addTab(page_out, "Outliers")
177
+ self.results.addTab(page_final, "Final model")
178
+
179
+ split = QSplitter()
180
+ split.addWidget(scroll)
181
+ split.addWidget(self.results)
182
+ split.setStretchFactor(1, 1)
183
+ QVBoxLayout(self).addWidget(split)
184
+ project.changed.connect(self._on_changed)
185
+ tasks.busy_changed.connect(self._busy)
186
+ self.load_settings()
187
+
188
+ # settings ----------------------------------------------------------- #
189
+
190
+ def _busy(self, busy: bool) -> None:
191
+ self.b_explore.setEnabled(not busy)
192
+ self.b_final.setEnabled(not busy)
193
+
194
+ def load_settings(self) -> None:
195
+ p = self.project.settings["parafac"]
196
+ ranks = [int(r) for r in p["ranks"]] or [2, 7]
197
+ self.r_from.setValue(min(ranks))
198
+ self.r_to.setValue(max(ranks))
199
+ self.n_init.setValue(int(p["n_init"]))
200
+ self.norm.setCurrentText(p["normalization"] or "none")
201
+ self.tol.setText(f"{float(p['tol']):g}")
202
+ self.max_iter.setValue(int(p["max_iter"]))
203
+ self.seed.setValue(int(p["random_state"]))
204
+ sh = p["split_half"]
205
+ self.sh_init.setValue(int(sh["n_init"]))
206
+ self.sh_strat.setText(", ".join(sh["stratify"]))
207
+ self.sh_thr.setValue(float(sh["threshold"]))
208
+ self.sh_seed.setValue(int(sh["random_state"]))
209
+ self.excluded.clear()
210
+ self.excluded.addItems([str(s) for s in p["exclude"]])
211
+ fin = p["final"]
212
+ if fin.get("rank"):
213
+ self.f_rank.setValue(int(fin["rank"]))
214
+ self.f_init.setValue(int(fin["n_init"]))
215
+ self.f_tol.setText(f"{float(fin['tol']):g}")
216
+ self.f_iter.setValue(int(fin["max_iter"]))
217
+
218
+ def _exploration_settings(self) -> dict | None:
219
+ try:
220
+ tol = _float(self.tol)
221
+ except ValueError:
222
+ QMessageBox.information(self, "eemkit", "Convergence must be a number, e.g. 1e-6.")
223
+ return None
224
+ if self.r_to.value() < self.r_from.value():
225
+ QMessageBox.information(self, "eemkit", "The component range is empty.")
226
+ return None
227
+ old = self.project.settings["parafac"]
228
+ return {**old, "ranks": list(range(self.r_from.value(), self.r_to.value() + 1)),
229
+ "n_init": self.n_init.value(),
230
+ "normalization": None if self.norm.currentText() == "none" else "norm",
231
+ "tol": tol, "max_iter": self.max_iter.value(), "random_state": self.seed.value(),
232
+ "exclude": [self.excluded.item(i).text() for i in range(self.excluded.count())],
233
+ "split_half": {**old["split_half"], "n_init": self.sh_init.value(),
234
+ "stratify": [s.strip() for s in self.sh_strat.text().split(",")
235
+ if s.strip()],
236
+ "threshold": self.sh_thr.value(),
237
+ "random_state": self.sh_seed.value()}}
238
+
239
+ def _store(self, values: dict) -> None:
240
+ old = self.project.settings["parafac"]
241
+ same_model = {k: v for k, v in old.items() if k != "final"} == \
242
+ {k: v for k, v in values.items() if k != "final"}
243
+ self.project.set_settings("parafac", values, None if same_model else "exploration")
244
+ if values.get("final") != old.get("final") and same_model:
245
+ self.project.invalidate("final")
246
+
247
+ # actions ------------------------------------------------------------ #
248
+
249
+ def _input(self) -> object | None:
250
+ if self.project.corrected is None:
251
+ QMessageBox.information(self, "eemkit", "Apply the corrections first.")
252
+ return None
253
+ try:
254
+ return pipeline.parafac_input(self.project.corrected, self.project.settings)
255
+ except Exception as exc: # noqa: BLE001 - settings problems shown to the user
256
+ QMessageBox.warning(self, "eemkit", str(exc))
257
+ return None
258
+
259
+ def explore(self) -> None:
260
+ values = self._exploration_settings()
261
+ if values is None:
262
+ return
263
+ self._store(values)
264
+ ds = self._input()
265
+ if ds is None:
266
+ return
267
+ settings = self.project.settings
268
+
269
+ def work(progress, cancelled):
270
+ return pipeline.explore(ds, settings, progress=progress, cancelled=cancelled)
271
+
272
+ def done(exploration):
273
+ self.project.exploration = exploration
274
+ self.project.changed.emit("exploration")
275
+ self.show_exploration()
276
+
277
+ n = len(values["ranks"])
278
+ self.tasks.run(work, done, f"Fitting {n} models with split-half validation…")
279
+
280
+ def fit_final(self) -> None:
281
+ values = self._exploration_settings()
282
+ if values is None:
283
+ return
284
+ try:
285
+ values["final"] = {**values["final"], "rank": self.f_rank.value(),
286
+ "n_init": self.f_init.value(), "tol": _float(self.f_tol),
287
+ "max_iter": self.f_iter.value()}
288
+ except ValueError:
289
+ QMessageBox.information(self, "eemkit", "Convergence must be a number, e.g. 1e-8.")
290
+ return
291
+ self._store(values)
292
+ ds = self._input()
293
+ if ds is None:
294
+ return
295
+ settings = self.project.settings
296
+
297
+ def work(progress, cancelled):
298
+ return pipeline.fit_final(ds, settings, progress=progress, cancelled=cancelled)
299
+
300
+ def done(result):
301
+ self.project.model, self.project.split = result
302
+ self.project.changed.emit("final")
303
+ self.show_final()
304
+ self.results.setCurrentIndex(3)
305
+
306
+ self.tasks.run(work, done, "Fitting the final model…")
307
+
308
+ def exclude_selected(self) -> None:
309
+ rows = sorted({i.row() for i in self.out_view.selectionModel().selectedRows()})
310
+ df = self.out_model.df
311
+ if not rows or df.empty:
312
+ return
313
+ current = {self.excluded.item(i).text() for i in range(self.excluded.count())}
314
+ for name in df.index[rows]:
315
+ if name not in current:
316
+ self.excluded.addItem(str(name))
317
+ self._store(self._exploration_settings() or self.project.settings["parafac"])
318
+ QMessageBox.information(self, "eemkit", "Samples set aside. Run the exploratory models "
319
+ "again to see the effect.")
320
+
321
+ def unexclude(self) -> None:
322
+ for item in self.excluded.selectedItems():
323
+ self.excluded.takeItem(self.excluded.row(item))
324
+ values = self._exploration_settings()
325
+ if values is not None:
326
+ self._store(values)
327
+
328
+ # views -------------------------------------------------------------- #
329
+
330
+ def _on_changed(self, what: str) -> None:
331
+ if what == "settings":
332
+ self.load_settings()
333
+ if what in ("exploration", "corrected") and self.project.exploration is None:
334
+ self.summary_model.set_frame(pd.DataFrame())
335
+ for panel in (self.diag_plot, self.load_plot, self.out_plot):
336
+ panel.clear("Run the exploratory models.")
337
+ self.out_model.set_frame(pd.DataFrame())
338
+ if what in ("final", "corrected") and self.project.model is None:
339
+ self.final_plot.clear("Fit the final model.")
340
+ self.final_text.setText("")
341
+ if what == "display":
342
+ self.draw_loadings()
343
+ if self.project.model is not None:
344
+ self.show_final()
345
+
346
+ def show_exploration(self) -> None:
347
+ ex = self.project.exploration
348
+ t = ex.table.copy()
349
+ self.summary_model.set_frame(t.round(4))
350
+ fig = plot.rank_diagnostics(
351
+ t, threshold=self.project.settings["parafac"]["split_half"]["threshold"], width=7.5)
352
+ self.diag_plot.show_figure(fig)
353
+ ranks = [str(r) for r in ex.models]
354
+ meta = [c for c in next(iter(ex.models.values())).coords
355
+ if c not in ("sample", "ex", "em", "component", "init", "stratum")
356
+ and next(iter(ex.models.values()))[c].dims == ("sample",)
357
+ and not c.endswith(("_file", "_time")) and not c.startswith(("eem_", "abs_"))]
358
+ for box, items in ((self.load_rank, ranks), (self.out_rank, ranks),
359
+ (self.out_by, ["(none)"] + meta)):
360
+ box.blockSignals(True)
361
+ box.clear()
362
+ box.addItems(items)
363
+ box.blockSignals(False)
364
+ self.draw_loadings()
365
+ self.draw_outliers()
366
+
367
+ def draw_loadings(self) -> None:
368
+ ex = self.project.exploration
369
+ if ex is None or not self.load_rank.currentText():
370
+ return
371
+ r = int(self.load_rank.currentText())
372
+ self.load_plot.show_figure(plot.components(ex.models[r], ex.splits[r], width=8.0,
373
+ cmap=self.project.eem_colours))
374
+
375
+ def draw_outliers(self) -> None:
376
+ ex = self.project.exploration
377
+ if ex is None or not self.out_rank.currentText():
378
+ return
379
+ m = ex.models[int(self.out_rank.currentText())]
380
+ by = self.out_by.currentText()
381
+ by = None if by in ("", "(none)") or m[by].to_series().nunique() > len(plot.CATEGORICAL) \
382
+ else by
383
+ self.out_plot.show_figure(plot.leverage(m, by=by, width=5.0))
384
+ df = pd.DataFrame({"leverage": m["leverage_sample"].values,
385
+ "residual SS": m["sse_sample_normalized"].values},
386
+ index=m["sample"].values).sort_values("leverage", ascending=False)
387
+ self.out_model.set_frame(df)
388
+
389
+ def show_final(self) -> None:
390
+ m, sh = self.project.model, self.project.split
391
+ a = m.attrs
392
+ verdict = "VALIDATED" if sh.attrs["validated"] else "NOT validated"
393
+ self.final_text.setText(
394
+ f"{m.sizes['component']} components, {m.sizes['sample']} samples. Explained variance "
395
+ f"{a['explained_variance_pct']:.3f} %, core consistency {a['core_consistency_pct']:.1f}"
396
+ f" %, {a['n_init_at_best_minimum']}/{a['n_init']} starts at the best minimum. "
397
+ f"Split-half minimum Tucker congruence {sh.attrs['min_tcc']:.4f}: {verdict}.")
398
+ self.final_plot.show_figure(plot.components(m, sh, width=8.0,
399
+ cmap=self.project.eem_colours))