ZaksPhysicsLibrary 1.3.0__tar.gz → 1.5.0__tar.gz

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 (24) hide show
  1. {zaksphysicslibrary-1.3.0/ZaksPhysicsLibrary.egg-info → zaksphysicslibrary-1.5.0}/PKG-INFO +43 -5
  2. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/__init__.py +22 -0
  3. zaksphysicslibrary-1.5.0/PhysicsLibrary/field_study_validation.py +433 -0
  4. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/processing_TDT.py +28 -4
  5. zaksphysicslibrary-1.5.0/PhysicsLibrary/text_field_study.py +405 -0
  6. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/README.md +37 -2
  7. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0/ZaksPhysicsLibrary.egg-info}/PKG-INFO +43 -5
  8. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +2 -0
  9. zaksphysicslibrary-1.5.0/ZaksPhysicsLibrary.egg-info/requires.txt +6 -0
  10. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/pyproject.toml +6 -3
  11. zaksphysicslibrary-1.3.0/ZaksPhysicsLibrary.egg-info/requires.txt +0 -3
  12. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/LICENSE +0 -0
  13. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/analysis.py +0 -0
  14. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/dataset.py +0 -0
  15. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/file_parser.py +0 -0
  16. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/file_parser_generic.py +0 -0
  17. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/__init__.py +0 -0
  18. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/oxysoft_loader.py +0 -0
  19. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
  20. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
  21. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/models.py +0 -0
  22. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
  23. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
  24. {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/setup.cfg +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.3.0
4
- Summary: Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data
3
+ Version: 1.5.0
4
+ Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/zakgm2/PhysicsLibrary
8
8
  Project-URL: Repository, https://github.com/zakgm2/PhysicsLibrary
9
9
  Project-URL: Changelog, https://github.com/zakgm2/PhysicsLibrary/blob/main/CHANGELOG.md
10
- Keywords: physics,neuroscience,fibre-photometry,NIRS,TDT,EFNMR,signal-processing
10
+ Keywords: physics,neuroscience,fibre-photometry,NIRS,TDT,EFNMR,signal-processing,text-analysis,nlp
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
@@ -21,6 +21,9 @@ License-File: LICENSE
21
21
  Requires-Dist: numpy
22
22
  Requires-Dist: scipy
23
23
  Requires-Dist: tdt
24
+ Requires-Dist: pandas
25
+ Requires-Dist: sentence-transformers
26
+ Requires-Dist: statsmodels
24
27
  Dynamic: license-file
25
28
 
26
29
  # PhysicsLibrary
@@ -38,6 +41,8 @@ Data processing and analysis library backing [PhysicsAnalysis](https://github.co
38
41
  - Generic **Excel / CSV / TSV / plain text**, with automatic sub-table detection for side-by-side data layouts on one sheet
39
42
  - **Processes signals** — bleach correction, denoising, Z-score PETH slicing, FFT with peak annotation, slope/segment analysis
40
43
  - **Fits curves** — linear, single/double exponential, exponential rise, Gaussian, sinusoidal, and a photon-entanglement visibility model, all via `scipy.optimize.curve_fit`
44
+ - **Analyses text field studies** — one JSON file per subject with several free-text fields; pick any pair of fields to compare directly (e.g. does the answer to one question track another for the same subject). Word counts, data-quality flagging, sentence-transformers embeddings, an optional delta-vector magnitude between two fields, and an optional paired-similarity metric per pair with a permutation test and a word-count confound check. Domain-agnostic — field names and which pairs to compare are supplied by the caller, nothing is hardcoded to one study
45
+ - **Validates the similarity metric statistically** — Benjamini-Hochberg FDR-corrected permutation-test p-values, Cohen's d effect size, a word-count-controlled OLS regression (statsmodels), a bootstrap confidence interval on the mean, and a leave-one-out sensitivity check, one row per field pair, with docstrings explaining what each statistic means
41
46
 
42
47
  ---
43
48
 
@@ -52,6 +57,8 @@ PhysicsLibrary/
52
57
  processing_TDT.py TDT tank reading, bleach correction, denoising, event markers
53
58
  analysis.py PETH/Z-score, FFT, slope segments, curve-fit runner
54
59
  models.py Parametric model functions for curve fitting
60
+ text_field_study.py Grouped-text-field study pipeline (embeddings, delta vector, paired similarity)
61
+ field_study_validation.py Statistical validation for the paired-similarity metric (permutation test, Cohen's d, regression, bootstrap CI, leave-one-out)
55
62
  loaders/
56
63
  tdt_loader.py Wraps processing_TDT into a Dataset
57
64
  oxysoft_loader.py Oxysoft .txt parsing (folder + single-file) into a Dataset
@@ -77,7 +84,8 @@ git+https://github.com/zakgm2/PhysicsLibrary.git
77
84
  ### Requirements
78
85
 
79
86
  - Python 3.10+
80
- - `numpy`, `scipy`, `tdt` (installed automatically)
87
+ - `numpy`, `scipy`, `tdt`, `pandas`, `sentence-transformers`, `statsmodels` (installed automatically)
88
+ - `sentence-transformers` pulls in `torch`/`transformers` as transitive dependencies — a genuinely heavy install (hundreds of MB) if you only need the signal-processing side; only actually loaded when you call `embed_text_fields`/`run_field_study_pipeline`
81
89
  - `openpyxl` — only needed for `.xlsx`/`.xls` files; imported lazily with a clear error if missing when you actually try to load Excel
82
90
 
83
91
  ---
@@ -125,6 +133,34 @@ result = pl.fit_model_to_segment(x_seg, y_seg, pl.single_exponential_model, p0_f
125
133
  result["popt"], result["r2"], result["y_fit"]
126
134
  ```
127
135
 
136
+ ```python
137
+ # Text field study — one JSON file per subject, e.g. P-0001.json. Pick
138
+ # pairs of fields to compare directly; no grouping concept needed.
139
+ fields = pl.peek_fields(folder_path) # see what fields exist before picking pairs
140
+ df = pl.run_field_study_pipeline(
141
+ folder_path,
142
+ text_fields=["q1", "q2", "q3", "q4"],
143
+ delta_pair=("q1", "q2"), # optional: how much did q2 change from q1
144
+ paired_fields=[("q1", "q3", "pair1")], # optional: does q1 track q3
145
+ )
146
+ # df has one row per subject: wordcount_<field>, low_quality_<field>, delta_magnitude,
147
+ # sim_<pair>, pvalue_<pair>, effect_size_<pair>, wc_confound_r_<pair>, ...
148
+ ```
149
+
150
+ ```python
151
+ # Statistical validation of the paired-similarity metric — one row per pair
152
+ summary = pl.run_validation_pipeline(
153
+ folder_path,
154
+ text_fields=["q1", "q3"],
155
+ paired_fields=[("q1", "q3", "pair1")],
156
+ )
157
+ # summary: p_value, p_value_fdr, cohens_d, wc_coef_a/b + wc_pvalue_a/b,
158
+ # regression_r_squared, ci_lower/ci_upper, n_flagged_loo, flagged_participant_ids
159
+ ```
160
+
161
+ See [FIELD_STUDY_METHODOLOGY.md](FIELD_STUDY_METHODOLOGY.md) for why each statistic in the
162
+ validation step is a sound, standard technique — useful if anyone asks.
163
+
128
164
  See [CHANGELOG.md](CHANGELOG.md) for the version history.
129
165
 
130
166
  ---
@@ -135,8 +171,10 @@ Everything importable from `PhysicsLibrary` directly:
135
171
 
136
172
  | Category | Names |
137
173
  |----------|-------|
138
- | Format detection | `choose_file`, `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
174
+ | Format detection | `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
139
175
  | Loading | `load_dataset`, `load_dataset_file`, `load_any_file`, `load_pt2` |
140
176
  | TDT processing | `process_tdt_folder`, `validate_tdt_folder`, `get_tdt_struct`, `get_plot_data`, `correct_bleaching`, `denoise_signal`, `get_event_markers` |
141
177
  | Analysis | `get_zscore_slice`, `smooth_signal`, `bin_for_heatmap`, `compute_fft_slice`, `annotate_fft_peaks`, `compute_slope_segment`, `fit_model_to_segment` |
142
178
  | Curve fit models | `linear_model`, `single_exponential_model`, `exponential_rise_model`, `double_exponential_model`, `gaussian_model`, `sinusoidal_model`, `visibility_model` |
179
+ | Text field study | `run_field_study_pipeline`, `load_field_study_folder`, `peek_fields`, `flag_low_quality`, `embed_text_fields`, `compute_delta_vector`, `compute_paired_similarity`, `permutation_test_similarity`, `wordcount_confound_check` |
180
+ | Field study validation | `run_validation_pipeline`, `build_validation_summary`, `cohens_d`, `benjamini_hochberg`, `wordcount_controlled_regression`, `bootstrap_mean_ci`, `leave_one_out_sensitivity` |
@@ -55,4 +55,26 @@ from .models import (
55
55
  exponential_rise_model,
56
56
  gaussian_model,
57
57
  sinusoidal_model,
58
+ )
59
+
60
+ from .text_field_study import (
61
+ run_field_study_pipeline,
62
+ load_field_study_folder,
63
+ peek_fields,
64
+ flag_low_quality,
65
+ embed_text_fields,
66
+ compute_delta_vector,
67
+ compute_paired_similarity,
68
+ permutation_test_similarity,
69
+ wordcount_confound_check,
70
+ )
71
+
72
+ from .field_study_validation import (
73
+ run_validation_pipeline,
74
+ build_validation_summary,
75
+ cohens_d,
76
+ benjamini_hochberg,
77
+ wordcount_controlled_regression,
78
+ bootstrap_mean_ci,
79
+ leave_one_out_sensitivity,
58
80
  )
@@ -0,0 +1,433 @@
1
+ """
2
+ field_study_validation.py
3
+ ----------------------------
4
+ Statistical validation for the paired-similarity metric computed by
5
+ text_field_study.py: for each field pair, is the same-subject
6
+ similarity meaningfully higher than chance, how big is that effect, is
7
+ it just a word-count artifact, how precise is the estimate, and how
8
+ much does it depend on any one subject?
9
+
10
+ run_validation_pipeline() is the single entry point (load -> embed ->
11
+ compute similarity -> validate, self-contained so it can run as its own
12
+ follow-up analysis without needing anything cached from a prior
13
+ run_field_study_pipeline() call); the stage functions below are also
14
+ exposed separately. Every function's docstring explains what the
15
+ statistic actually means, for interpreting/writing up results — not
16
+ just how to call it.
17
+ """
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+
22
+ from .text_field_study import (
23
+ load_field_study_folder,
24
+ embed_text_fields,
25
+ compute_paired_similarity,
26
+ permutation_test_similarity,
27
+ )
28
+
29
+
30
+ def cohens_d(group1, group2):
31
+ """
32
+ Cohen's d: how far apart two distributions' means are, in units of
33
+ their pooled standard deviation. Unlike a p-value (which just says
34
+ "probably not the same"), this says how *big* the difference is —
35
+ the conventional rough guide is ~0.2 small, ~0.5 medium, ~0.8 large,
36
+ but those thresholds are a loose convention, not a hard rule.
37
+
38
+ Here: group1 is typically the same-subject similarity values (one
39
+ per subject), group2 the pooled null (chance-pairing) similarity
40
+ values — d tells you how far the "real" pairing sits from chance
41
+ pairing, in a way that doesn't depend on sample size the way a
42
+ p-value does.
43
+
44
+ Parameters
45
+ ----------
46
+ group1, group2 : array-like
47
+
48
+ Returns
49
+ -------
50
+ float
51
+ """
52
+ group1 = np.asarray(group1, dtype=float)
53
+ group2 = np.asarray(group2, dtype=float)
54
+ n1, n2 = len(group1), len(group2)
55
+ if n1 < 2 or n2 < 2:
56
+ # A standard deviation isn't defined from a single value — not
57
+ # enough subjects/null draws yet for this to mean anything.
58
+ return float("nan")
59
+ var1, var2 = group1.var(ddof=1), group2.var(ddof=1)
60
+ pooled_std = np.sqrt(((n1 - 1) * var1 + (n2 - 1) * var2) / (n1 + n2 - 2))
61
+ if pooled_std == 0:
62
+ return float("nan")
63
+ return float((group1.mean() - group2.mean()) / pooled_std)
64
+
65
+
66
+ def benjamini_hochberg(p_values):
67
+ """
68
+ Benjamini-Hochberg false discovery rate correction. Running several
69
+ hypothesis tests (here: one permutation test per field pair) inflates
70
+ the chance that at least one comes back "significant" by luck alone —
71
+ this adjusts each p-value upward to control the *expected proportion*
72
+ of false positives among the tests you call significant, which is
73
+ less conservative (more power) than a stricter correction like
74
+ Bonferroni while still accounting for doing multiple tests.
75
+
76
+ The output is often called a "q-value": if you use q < 0.05 as your
77
+ cutoff, you're accepting that on average 5% of the pairs you call
78
+ significant across repeated studies like this one would be false
79
+ positives — not that any individual result has a 5% chance of being
80
+ wrong.
81
+
82
+ Parameters
83
+ ----------
84
+ p_values : array-like
85
+
86
+ Returns
87
+ -------
88
+ ndarray
89
+ Corrected p-values (q-values), same order as the input.
90
+ """
91
+ p_values = np.asarray(p_values, dtype=float)
92
+ n = len(p_values)
93
+ order = np.argsort(p_values)
94
+ ranked = p_values[order]
95
+ corrected = ranked * n / np.arange(1, n + 1)
96
+ # Enforce monotonicity (a lower-ranked p-value's correction can never
97
+ # end up smaller than a higher-ranked one's) via a reverse cumulative
98
+ # minimum — the standard BH step-up procedure.
99
+ corrected = np.minimum.accumulate(corrected[::-1])[::-1]
100
+ corrected = np.clip(corrected, 0, 1)
101
+ out = np.empty(n)
102
+ out[order] = corrected
103
+ return out
104
+
105
+
106
+ def wordcount_controlled_regression(df, field_a, field_b, sim_col):
107
+ """
108
+ OLS regression: same-subject similarity ~ wordcount_<field_a> +
109
+ wordcount_<field_b>. Tests whether verbosity in either field predicts
110
+ similarity, controlling for the other. If a word-count coefficient
111
+ comes back significant (small p-value), that field's length is
112
+ predicting similarity independent of actual content overlap — a
113
+ "longer answers just look more similar" confound that would weaken a
114
+ claim based on the similarity metric alone. If neither is
115
+ significant, the observed similarity isn't just a length artifact.
116
+
117
+ Requires the `statsmodels` package.
118
+
119
+ Parameters
120
+ ----------
121
+ df : pandas.DataFrame
122
+ Must have wordcount_<field_a>, wordcount_<field_b>, and sim_col
123
+ columns (i.e. called on the output of run_field_study_pipeline,
124
+ or with those columns present some other way).
125
+ field_a, field_b : str
126
+ sim_col : str
127
+ The similarity column to use as the outcome, e.g. 'sim_<name>'.
128
+
129
+ Returns
130
+ -------
131
+ dict
132
+ r_squared : float
133
+ Proportion of variance in similarity explained by both word
134
+ counts together — small values mean word count barely
135
+ matters, which is the reassuring result for the confound
136
+ check.
137
+ coef_const, coef_wordcount_<field_a>, coef_wordcount_<field_b> : float
138
+ Regression coefficients (the intercept, and how much
139
+ similarity is predicted to change per additional word in
140
+ each field).
141
+ pvalue_const, pvalue_wordcount_<field_a>, pvalue_wordcount_<field_b> : float
142
+ Whether each coefficient is distinguishable from zero.
143
+ model : statsmodels RegressionResults
144
+ The full fitted model, for anyone who wants more than the
145
+ summary numbers above (e.g. confidence intervals, residual
146
+ diagnostics).
147
+ """
148
+ import statsmodels.api as sm
149
+
150
+ x = df[[f"wordcount_{field_a}", f"wordcount_{field_b}"]].astype(float)
151
+ x = sm.add_constant(x)
152
+ y = df[sim_col].astype(float)
153
+
154
+ # 3 parameters (intercept + 2 word-count covariates) need at least 3
155
+ # subjects to not be a singular/degenerate fit — with fewer, there's
156
+ # no unique answer, so return NaNs instead of letting statsmodels
157
+ # produce a nonsensical or all-NaN result silently.
158
+ if len(df) < x.shape[1]:
159
+ result = {"r_squared": float("nan"), "model": None}
160
+ for name in x.columns:
161
+ result[f"coef_{name}"] = float("nan")
162
+ result[f"pvalue_{name}"] = float("nan")
163
+ return result
164
+
165
+ model = sm.OLS(y, x).fit()
166
+
167
+ result = {"r_squared": float(model.rsquared), "model": model}
168
+ for name in x.columns:
169
+ result[f"coef_{name}"] = float(model.params[name])
170
+ result[f"pvalue_{name}"] = float(model.pvalues[name])
171
+ return result
172
+
173
+
174
+ def bootstrap_mean_ci(values, n_boot=1000, ci=0.95, rng_seed=None):
175
+ """
176
+ Bootstrap confidence interval on the mean: resample the subjects
177
+ (with replacement, same sample size) n_boot times, take the mean
178
+ each time, and report the [lower, upper] percentiles of that
179
+ distribution of means. This doesn't assume the underlying similarity
180
+ values are normally distributed (unlike a textbook mean ± 1.96*SE
181
+ interval) — it just asks "how much would my estimate of the mean
182
+ wobble if I'd happened to sample a slightly different set of
183
+ subjects from the same population."
184
+
185
+ A narrow interval means the estimate is precise (more subjects, or
186
+ less subject-to-subject variability); a wide one means treat the
187
+ point estimate cautiously — you don't have enough subjects yet to
188
+ pin it down tightly.
189
+
190
+ Parameters
191
+ ----------
192
+ values : array-like
193
+ Per-subject values (e.g. same-subject similarity for one pair).
194
+ n_boot : int
195
+ Number of resamples.
196
+ ci : float
197
+ Confidence level, e.g. 0.95 for a 95% interval.
198
+ rng_seed : int or None
199
+
200
+ Returns
201
+ -------
202
+ dict
203
+ mean, ci_lower, ci_upper, boot_means (the full resampled-means
204
+ array, for anyone who wants to plot its distribution).
205
+ """
206
+ values = np.asarray(values, dtype=float)
207
+ rng = np.random.default_rng(rng_seed)
208
+ n = len(values)
209
+ boot_means = np.empty(n_boot)
210
+ for i in range(n_boot):
211
+ boot_means[i] = rng.choice(values, size=n, replace=True).mean()
212
+
213
+ alpha = 1 - ci
214
+ lower = np.percentile(boot_means, 100 * alpha / 2)
215
+ upper = np.percentile(boot_means, 100 * (1 - alpha / 2))
216
+ return {
217
+ "mean": float(values.mean()),
218
+ "ci_lower": float(lower),
219
+ "ci_upper": float(upper),
220
+ "boot_means": boot_means,
221
+ }
222
+
223
+
224
+ def leave_one_out_sensitivity(values, ids=None):
225
+ """
226
+ Leave-one-out sensitivity check: recompute the mean with each subject
227
+ dropped, one at a time, and flag any subject whose removal shifts the
228
+ mean by more than one standard deviation of the resulting
229
+ (n_subjects-long) distribution of leave-one-out means.
230
+
231
+ This catches a single unusual subject quietly dominating your
232
+ result — e.g. one participant with an oddly high or low similarity
233
+ score dragging the whole-group mean toward it. A flagged subject
234
+ isn't necessarily wrong or worth excluding — it's worth looking at
235
+ that subject's actual data and deciding, not an automatic exclusion
236
+ rule.
237
+
238
+ Parameters
239
+ ----------
240
+ values : array-like
241
+ Per-subject values (e.g. same-subject similarity for one pair).
242
+ ids : array-like or None
243
+ Subject identifiers matching values' order, for readable output.
244
+ Defaults to positional indices if not given.
245
+
246
+ Returns
247
+ -------
248
+ dict
249
+ full_mean : float
250
+ loo_means : ndarray
251
+ The mean with each subject left out, in input order.
252
+ shifts : ndarray
253
+ full_mean minus each loo_means entry — how far removing that
254
+ subject moved the mean.
255
+ threshold : float
256
+ One standard deviation of loo_means — the cutoff used to flag.
257
+ flagged_ids : list
258
+ Subject ids whose |shift| exceeds threshold.
259
+ """
260
+ values = np.asarray(values, dtype=float)
261
+ n = len(values)
262
+ if ids is None:
263
+ ids = list(range(n))
264
+ else:
265
+ ids = list(ids)
266
+
267
+ full_mean = float(values.mean()) if n > 0 else float("nan")
268
+ if n < 2:
269
+ # Nothing to leave out-and-compare with fewer than 2 subjects —
270
+ # "does removing one subject change the mean" isn't meaningful
271
+ # when there's only one (or zero) to begin with.
272
+ return {
273
+ "full_mean": full_mean,
274
+ "loo_means": np.array([]),
275
+ "shifts": np.array([]),
276
+ "threshold": 0.0,
277
+ "flagged_ids": [],
278
+ }
279
+
280
+ loo_means = np.array([np.delete(values, i).mean() for i in range(n)])
281
+ shifts = full_mean - loo_means
282
+ threshold = float(loo_means.std(ddof=1))
283
+ flagged_mask = np.abs(shifts) > threshold
284
+
285
+ return {
286
+ "full_mean": full_mean,
287
+ "loo_means": loo_means,
288
+ "shifts": shifts,
289
+ "threshold": threshold,
290
+ "flagged_ids": [ids[i] for i in range(n) if flagged_mask[i]],
291
+ }
292
+
293
+
294
+ def build_validation_summary(df, paired_fields, similarity_results, id_field="participant_id", n_boot=1000, rng_seed=None):
295
+ """
296
+ Folds every check above into one summary table, one row per field
297
+ pair — see each column's meaning below (and the stage functions'
298
+ own docstrings for the full explanation of each statistic).
299
+
300
+ Parameters
301
+ ----------
302
+ df : pandas.DataFrame
303
+ Needs wordcount_<field> columns for every field in paired_fields,
304
+ and id_field if you want subject ids (not positional indices) in
305
+ flagged_participant_ids.
306
+ paired_fields : list of (field_a, field_b, pair_name)
307
+ similarity_results : dict
308
+ As returned by compute_paired_similarity, computed with these
309
+ same paired_fields — reused rather than recomputed here, since
310
+ recomputing similarity means recomputing the (expensive)
311
+ embeddings.
312
+ id_field : str
313
+ n_boot : int
314
+ rng_seed : int or None
315
+
316
+ Returns
317
+ -------
318
+ pandas.DataFrame
319
+ One row per pair:
320
+ - pair, field_a, field_b : which fields this row covers
321
+ - observed_mean_similarity : mean same-subject similarity
322
+ - p_value : permutation-test p-value (is the observed mean
323
+ higher than chance-pairing, one-tailed)
324
+ - p_value_fdr : p_value after Benjamini-Hochberg correction
325
+ across all pairs in this table — use this one, not the raw
326
+ p_value, once you're looking at more than one pair
327
+ - cohens_d : effect size (same-subject similarity vs. the pooled
328
+ chance-pairing null), pooled-std units
329
+ - wc_coef_a/wc_pvalue_a, wc_coef_b/wc_pvalue_b : word-count
330
+ regression coefficient/p-value for field_a's and field_b's
331
+ word count respectively, controlling for each other
332
+ (wordcount_controlled_regression) — a significant coefficient
333
+ here means part of the similarity effect may just be verbosity
334
+ - regression_r_squared : how much of the similarity variance
335
+ both word counts together explain (low = reassuring)
336
+ - ci_lower, ci_upper : bootstrap 95% CI on observed_mean_similarity
337
+ - n_flagged_loo : how many subjects were flagged by the
338
+ leave-one-out check (0 is the reassuring result)
339
+ - flagged_participant_ids : which ones, comma-separated
340
+ """
341
+ rows = []
342
+ p_values = []
343
+ for field_a, field_b, name in paired_fields:
344
+ result = similarity_results[name]
345
+ same = result["same_subject_similarity"]
346
+ null_values = result["null_values"]
347
+ null_shuffle_means = result["null_shuffle_means"]
348
+
349
+ perm = permutation_test_similarity(same, null_shuffle_means)
350
+ d = cohens_d(same, null_values)
351
+ reg = wordcount_controlled_regression(df, field_a, field_b, f"sim_{name}")
352
+ ci = bootstrap_mean_ci(same, n_boot=n_boot, rng_seed=rng_seed)
353
+ ids = df[id_field].tolist() if id_field in df.columns else None
354
+ loo = leave_one_out_sensitivity(same, ids=ids)
355
+
356
+ rows.append({
357
+ "pair": name,
358
+ "field_a": field_a,
359
+ "field_b": field_b,
360
+ "observed_mean_similarity": perm["observed_mean_similarity"],
361
+ "p_value": perm["p_value"],
362
+ "cohens_d": d,
363
+ "wc_coef_a": reg[f"coef_wordcount_{field_a}"],
364
+ "wc_pvalue_a": reg[f"pvalue_wordcount_{field_a}"],
365
+ "wc_coef_b": reg[f"coef_wordcount_{field_b}"],
366
+ "wc_pvalue_b": reg[f"pvalue_wordcount_{field_b}"],
367
+ "regression_r_squared": reg["r_squared"],
368
+ "ci_lower": ci["ci_lower"],
369
+ "ci_upper": ci["ci_upper"],
370
+ "n_flagged_loo": len(loo["flagged_ids"]),
371
+ "flagged_participant_ids": ",".join(str(x) for x in loo["flagged_ids"]),
372
+ })
373
+ p_values.append(perm["p_value"])
374
+
375
+ p_fdr = benjamini_hochberg(np.array(p_values))
376
+ for row, q in zip(rows, p_fdr):
377
+ row["p_value_fdr"] = float(q)
378
+
379
+ return pd.DataFrame(rows)
380
+
381
+
382
+ def run_validation_pipeline(folder_path, text_fields, paired_fields,
383
+ model_name="all-MiniLM-L6-v2", n_null=200,
384
+ n_boot=1000, rng_seed=None, file_glob="P-*.json",
385
+ id_field="participant_id"):
386
+ """
387
+ Full validation pipeline: load -> embed -> compute paired similarity
388
+ -> build the validation summary table (see build_validation_summary
389
+ for what each column means). Self-contained — recomputes embeddings
390
+ rather than reusing a prior run_field_study_pipeline() call, so this
391
+ can run as its own follow-up analysis.
392
+
393
+ Parameters
394
+ ----------
395
+ folder_path : str
396
+ text_fields : list of str
397
+ Same meaning as in run_field_study_pipeline.
398
+ paired_fields : list of (field_a, field_b, pair_name)
399
+ Which fields to compare. Required here (unlike
400
+ run_field_study_pipeline, where it's optional) — there's nothing
401
+ to validate without at least one pair.
402
+ model_name : str
403
+ n_null : int
404
+ Permutation shuffles for the null distribution.
405
+ n_boot : int
406
+ Bootstrap resamples for the confidence interval.
407
+ rng_seed : int or None
408
+ file_glob : str
409
+ id_field : str
410
+ Column to use as subject identifiers in the leave-one-out flags.
411
+
412
+ Returns
413
+ -------
414
+ pandas.DataFrame
415
+ See build_validation_summary.
416
+ """
417
+ if not paired_fields:
418
+ raise ValueError("paired_fields is required — nothing to validate without at least one pair.")
419
+
420
+ df = load_field_study_folder(folder_path, text_fields, file_glob=file_glob)
421
+ fields_to_embed = sorted({f for field_a, field_b, _ in paired_fields for f in (field_a, field_b)})
422
+ embeddings = embed_text_fields(df, fields_to_embed, model_name=model_name)
423
+ similarity_results = compute_paired_similarity(embeddings, paired_fields, n_null=n_null, rng_seed=rng_seed)
424
+
425
+ # build_validation_summary's regression step needs sim_<name> columns
426
+ # on df, same as run_field_study_pipeline adds them.
427
+ for field_a, field_b, name in paired_fields:
428
+ df[f"sim_{name}"] = similarity_results[name]["same_subject_similarity"]
429
+
430
+ return build_validation_summary(
431
+ df, paired_fields, similarity_results,
432
+ id_field=id_field, n_boot=n_boot, rng_seed=rng_seed,
433
+ )
@@ -278,10 +278,19 @@ def get_event_markers(data):
278
278
  toggle each store's markers independently instead of dumping every
279
279
  store onto the plot at once (which overlaps into an unreadable mess).
280
280
 
281
+ Every epoc is a state that goes high (onset) and later low (offset) —
282
+ for a lever press that's press/release, for a pump or light that's
283
+ on/off. Both are emitted here, each tagged with a 'phase' key ('high'
284
+ for onset, 'low' for offset), so a caller can decide per store whether
285
+ it cares about both edges (e.g. how long the pump ran) or only one
286
+ (e.g. just the press, not the release) instead of that decision being
287
+ baked into the library.
288
+
281
289
  The 'Note' store keeps its original behaviour: each onset becomes a
282
- marker labeled with the actual note text (from Notes.txt), since
283
- that's the one store meant for free-text annotations. Every other
284
- epoc store gets one marker per onset event, labeled with the store
290
+ marker labeled with the actual note text (from Notes.txt), and has no
291
+ offset/'low' marker free-text annotations are instantaneous, not a
292
+ state with a duration. Every other epoc store gets one 'high' marker
293
+ per onset and one 'low' marker per offset, both labeled with the store
285
294
  name.
286
295
 
287
296
  Nothing about which stores exist is hardcoded — this walks whatever
@@ -294,7 +303,8 @@ def get_event_markers(data):
294
303
  - time : float
295
304
  - label : str
296
305
  - color : str
297
- - store : str (source epoc store name, for grouping/toggling)
306
+ - store : str (source epoc store name, for grouping/toggling)
307
+ - phase : str ('high'=onset, 'low'=offset; omitted for Note markers)
298
308
  """
299
309
  if not hasattr(data, 'epocs'):
300
310
  return []
@@ -336,6 +346,20 @@ def get_event_markers(data):
336
346
  'label': display_name,
337
347
  'color': color,
338
348
  'store': display_name,
349
+ 'phase': 'high',
350
+ })
351
+ offsets = getattr(epoc, 'offset', [])
352
+ for t in offsets:
353
+ # TDT represents a strobe still active at recording end with
354
+ # offset=inf — not a real timestamp, nothing to plot.
355
+ if not np.isfinite(t):
356
+ continue
357
+ markers.append({
358
+ 'time': float(t),
359
+ 'label': display_name,
360
+ 'color': color,
361
+ 'store': display_name,
362
+ 'phase': 'low',
339
363
  })
340
364
 
341
365
  return markers
@@ -0,0 +1,405 @@
1
+ """
2
+ text_field_study.py
3
+ ---------------------
4
+ Generic pipeline for a study where each subject produces one JSON file
5
+ with several free-text fields, and you want to compare specific pairs
6
+ of fields to each other (e.g. "does this answer track that one for the
7
+ same subject"). Domain-agnostic by design — field names and which pairs
8
+ to compare are supplied by the caller via arguments, not hardcoded
9
+ here, so this works for any text-field study, not one specific one.
10
+
11
+ run_field_study_pipeline() is the single entry point for running the
12
+ whole thing at once; the stage functions below are also exposed for
13
+ anyone who wants an intermediate result (embeddings, raw null
14
+ distributions) directly.
15
+ """
16
+
17
+ import glob
18
+ import json
19
+ import os
20
+
21
+ import numpy as np
22
+ import pandas as pd
23
+ from scipy import stats
24
+
25
+
26
+ def peek_fields(folder_path, file_glob="P-*.json"):
27
+ """
28
+ Returns the field names (dict keys) found in the first file matching
29
+ file_glob in folder_path, without loading the whole folder — lets a
30
+ caller (e.g. a GUI) show a user their actual field names to assign
31
+ to groups, rather than requiring them to already know/type them.
32
+
33
+ Parameters
34
+ ----------
35
+ folder_path : str
36
+ file_glob : str
37
+
38
+ Returns
39
+ -------
40
+ list of str
41
+ """
42
+ paths = sorted(glob.glob(os.path.join(folder_path, file_glob)))
43
+ if not paths:
44
+ raise ValueError(f"No files matching '{file_glob}' found in {folder_path}")
45
+ with open(paths[0], "r", encoding="utf-8") as fh:
46
+ return list(json.load(fh).keys())
47
+
48
+
49
+ def load_field_study_folder(folder_path, text_fields, file_glob="P-*.json"):
50
+ """
51
+ Load every file matching file_glob in folder_path into a DataFrame
52
+ (one row per subject) and add a wordcount_<field> column for each
53
+ text field — a covariate for later analysis, not itself a feature.
54
+
55
+ Parameters
56
+ ----------
57
+ folder_path : str
58
+ text_fields : list of str
59
+ Every free-text field to word-count.
60
+ file_glob : str
61
+ Filename pattern within folder_path, e.g. "P-*.json".
62
+
63
+ Returns
64
+ -------
65
+ pandas.DataFrame
66
+ """
67
+ paths = sorted(glob.glob(os.path.join(folder_path, file_glob)))
68
+ if not paths:
69
+ raise ValueError(f"No files matching '{file_glob}' found in {folder_path}")
70
+
71
+ records = []
72
+ for path in paths:
73
+ with open(path, "r", encoding="utf-8") as fh:
74
+ records.append(json.load(fh))
75
+ df = pd.DataFrame(records)
76
+
77
+ for field in text_fields:
78
+ text = df[field].fillna("") if field in df.columns else ""
79
+ df[f"wordcount_{field}"] = text.apply(lambda s: len(str(s).split()))
80
+
81
+ return df
82
+
83
+
84
+ def flag_low_quality(df, text_fields, min_words=5):
85
+ """
86
+ Flags (doesn't drop) rows with near-empty responses — noise, not
87
+ signal, but the caller decides whether/how to exclude them, so
88
+ nothing here is silently discarded.
89
+
90
+ Adds a low_quality_<field> bool column per text field (True if that
91
+ field's word count is below min_words) and an any_low_quality column
92
+ (True if any field is flagged for that subject). Must be called
93
+ after word counts exist (i.e. after load_field_study_folder).
94
+
95
+ Parameters
96
+ ----------
97
+ df : pandas.DataFrame
98
+ As returned by load_field_study_folder — must have
99
+ wordcount_<field> columns for text_fields.
100
+ text_fields : list of str
101
+ min_words : int
102
+ Fields with fewer words than this are flagged.
103
+
104
+ Returns
105
+ -------
106
+ pandas.DataFrame
107
+ The same df, with the new columns added.
108
+ """
109
+ flag_cols = []
110
+ for field in text_fields:
111
+ col = f"low_quality_{field}"
112
+ df[col] = df[f"wordcount_{field}"] < min_words
113
+ flag_cols.append(col)
114
+ df["any_low_quality"] = df[flag_cols].any(axis=1)
115
+ return df
116
+
117
+
118
+ def embed_text_fields(df, fields, model_name="all-MiniLM-L6-v2"):
119
+ """
120
+ Embed each of `fields` with a sentence-transformers model,
121
+ L2-normalized (so a plain dot product below is the cosine similarity).
122
+ Flat by field name — nothing about which fields get compared to
123
+ which needs to be decided at embedding time, only when you call
124
+ compute_delta_vector/compute_paired_similarity afterward.
125
+
126
+ Parameters
127
+ ----------
128
+ df : pandas.DataFrame
129
+ As returned by load_field_study_folder.
130
+ fields : list of str
131
+ Which fields to embed (typically every field referenced by your
132
+ paired_fields/delta_pair — no need to embed fields you're not
133
+ actually comparing).
134
+ model_name : str
135
+
136
+ Returns
137
+ -------
138
+ dict
139
+ {field: (n_subjects, dim) ndarray, ...}
140
+ """
141
+ from sentence_transformers import SentenceTransformer
142
+
143
+ model = SentenceTransformer(model_name)
144
+ embeddings = {}
145
+ for field in fields:
146
+ texts = df[field].fillna("").astype(str).tolist()
147
+ embeddings[field] = np.asarray(model.encode(texts, normalize_embeddings=True))
148
+ return embeddings
149
+
150
+
151
+ def compute_delta_vector(embeddings, field_from, field_to):
152
+ """
153
+ delta = vec(field_to) - vec(field_from), per subject, plus its
154
+ magnitude — e.g. how much a subject's answer changed between two
155
+ related fields.
156
+
157
+ Parameters
158
+ ----------
159
+ embeddings : dict
160
+ As returned by embed_text_fields.
161
+ field_from, field_to : str
162
+
163
+ Returns
164
+ -------
165
+ (delta_vectors, delta_magnitude) : (ndarray, ndarray)
166
+ """
167
+ vectors = embeddings[field_to] - embeddings[field_from]
168
+ magnitude = np.linalg.norm(vectors, axis=1)
169
+ return vectors, magnitude
170
+
171
+
172
+ def compute_paired_similarity(embeddings, paired_fields, n_null=200, rng_seed=None):
173
+ """
174
+ Cosine similarity between each pair of fields for the same subject,
175
+ plus a null distribution built by repeatedly shuffling one field's
176
+ vectors across subjects and recomputing similarity — the basis for a
177
+ later permutation test on whether same-subject similarity is higher
178
+ than chance pairing.
179
+
180
+ Each shuffle is a full permutation across all subjects at once; all
181
+ n_null * n_subjects values across every shuffle are pooled into one
182
+ null distribution per pair (not per subject — "chance pairing" is a
183
+ property of the pairing process, not of any one subject). A
184
+ permutation occasionally maps a subject to themselves (identity
185
+ mapping isn't excluded); for a reasonable sample size this is a
186
+ negligible bias, not worth the extra complexity of a derangement.
187
+
188
+ Parameters
189
+ ----------
190
+ embeddings : dict
191
+ As returned by embed_text_fields.
192
+ paired_fields : list of (field_a, field_b, pair_name)
193
+ Which two fields to compare, and a name for that comparison.
194
+ n_null : int
195
+ Number of random shuffles to build the null distribution from.
196
+ rng_seed : int or None
197
+
198
+ Returns
199
+ -------
200
+ dict
201
+ {pair_name: {
202
+ 'same_subject_similarity': (n_subjects,) ndarray,
203
+ 'null_mean': float,
204
+ 'null_std': float,
205
+ 'null_values': (n_null * n_subjects,) ndarray,
206
+ 'null_shuffle_means': (n_null,) ndarray,
207
+ the mean similarity within each individual shuffle —
208
+ this (not null_values) is the actual null distribution
209
+ for a permutation test of the *mean* same-subject
210
+ similarity; see permutation_test_similarity.
211
+ }, ...}
212
+ """
213
+ rng = np.random.default_rng(rng_seed)
214
+ results = {}
215
+ for field_a, field_b, name in paired_fields:
216
+ vecs_a = embeddings[field_a]
217
+ vecs_b = embeddings[field_b]
218
+ n = vecs_a.shape[0]
219
+
220
+ same_subject_similarity = np.sum(vecs_a * vecs_b, axis=1)
221
+
222
+ null_values = np.empty(n_null * n)
223
+ null_shuffle_means = np.empty(n_null)
224
+ for i in range(n_null):
225
+ shuffled = vecs_b[rng.permutation(n)]
226
+ shuffle_sims = np.sum(vecs_a * shuffled, axis=1)
227
+ null_values[i * n:(i + 1) * n] = shuffle_sims
228
+ null_shuffle_means[i] = shuffle_sims.mean()
229
+
230
+ results[name] = {
231
+ "same_subject_similarity": same_subject_similarity,
232
+ "null_mean": float(null_values.mean()),
233
+ "null_std": float(null_values.std()),
234
+ "null_values": null_values,
235
+ "null_shuffle_means": null_shuffle_means,
236
+ }
237
+ return results
238
+
239
+
240
+ def permutation_test_similarity(same_subject_similarity, null_shuffle_means):
241
+ """
242
+ Permutation test: is the observed mean same-subject similarity
243
+ higher than expected under random pairing? Compares the observed
244
+ mean against the distribution of per-shuffle means (not the pooled
245
+ individual null_values — the test statistic is the mean, so its
246
+ null distribution has to be built from the same statistic computed
247
+ under each shuffle, not from individual chance-pairing similarities).
248
+
249
+ One-tailed (is same-subject similarity higher than chance, not just
250
+ different) — add-one smoothing avoids a p-value of exactly 0 for a
251
+ finite number of shuffles.
252
+
253
+ Parameters
254
+ ----------
255
+ same_subject_similarity : ndarray
256
+ Per-subject observed similarity (one pair's worth).
257
+ null_shuffle_means : ndarray
258
+ Per-shuffle mean similarity, as returned by
259
+ compute_paired_similarity.
260
+
261
+ Returns
262
+ -------
263
+ dict
264
+ observed_mean_similarity, p_value,
265
+ effect_size (observed mean minus the null shuffle-mean
266
+ distribution's own mean, in that distribution's std units),
267
+ null_shuffle_mean, null_shuffle_std.
268
+ """
269
+ observed = float(np.mean(same_subject_similarity))
270
+ n_null = len(null_shuffle_means)
271
+ p_value = (np.sum(null_shuffle_means >= observed) + 1) / (n_null + 1)
272
+
273
+ shuffle_mean = float(null_shuffle_means.mean())
274
+ shuffle_std = float(null_shuffle_means.std())
275
+ effect_size = (observed - shuffle_mean) / shuffle_std if shuffle_std > 0 else float("nan")
276
+
277
+ return {
278
+ "observed_mean_similarity": observed,
279
+ "p_value": float(p_value),
280
+ "effect_size": effect_size,
281
+ "null_shuffle_mean": shuffle_mean,
282
+ "null_shuffle_std": shuffle_std,
283
+ }
284
+
285
+
286
+ def wordcount_confound_check(df, paired_fields):
287
+ """
288
+ Checks whether word count predicts similarity — a "more words = more
289
+ similar" confound would show up as a significant positive
290
+ correlation here, which would undercut any claim based on the
291
+ similarity metric alone.
292
+
293
+ Pearson correlation between each pair's combined word count
294
+ (wordcount_<field_a> + wordcount_<field_b>) and its similarity
295
+ column (sim_<name>) across subjects.
296
+
297
+ Parameters
298
+ ----------
299
+ df : pandas.DataFrame
300
+ Must have wordcount_<field_a>, wordcount_<field_b>, and
301
+ sim_<name> columns for each pair (i.e. called after
302
+ run_field_study_pipeline's word-count and similarity steps).
303
+ paired_fields : list of (field_a, field_b, pair_name)
304
+
305
+ Returns
306
+ -------
307
+ dict
308
+ {pair_name: {'r': float, 'p_value': float}, ...}
309
+ Both are NaN if there are fewer than 2 subjects, or every
310
+ subject has the same word count — a correlation genuinely isn't
311
+ defined in either case, not something to error out over.
312
+ """
313
+ results = {}
314
+ for field_a, field_b, name in paired_fields:
315
+ wordcount = df[f"wordcount_{field_a}"] + df[f"wordcount_{field_b}"]
316
+ similarity = df[f"sim_{name}"]
317
+ if len(wordcount) < 2 or wordcount.nunique() < 2 or similarity.nunique() < 2:
318
+ results[name] = {"r": float("nan"), "p_value": float("nan")}
319
+ continue
320
+ r, p_value = stats.pearsonr(wordcount, similarity)
321
+ results[name] = {"r": float(r), "p_value": float(p_value)}
322
+ return results
323
+
324
+
325
+ def run_field_study_pipeline(folder_path, text_fields, delta_pair=None, paired_fields=None,
326
+ model_name="all-MiniLM-L6-v2", n_null=200, rng_seed=None,
327
+ file_glob="P-*.json", min_words=5):
328
+ """
329
+ Full pipeline: load -> flag low-quality responses -> embed -> optional
330
+ delta vector -> optional paired similarity (+ permutation test +
331
+ word-count confound check), folded into one DataFrame (one row per
332
+ subject). This is the single entry point for running the whole thing
333
+ at once (e.g. as one background job); the stage functions above are
334
+ available separately for anyone who wants an intermediate result
335
+ (e.g. the raw null distributions, not carried into the DataFrame
336
+ here — only their mean/std/permutation-test summary are).
337
+
338
+ Parameters
339
+ ----------
340
+ folder_path : str
341
+ text_fields : list of str
342
+ Every free-text field to load/word-count.
343
+ delta_pair : (field_from, field_to) or None
344
+ If given, adds a delta_magnitude column:
345
+ ||vec(field_to) - vec(field_from)||.
346
+ paired_fields : list of (field_a, field_b, pair_name) or None
347
+ If given, adds per pair: sim_<name>, null_mean_<name>,
348
+ null_std_<name> (individual chance-pairing similarity stats),
349
+ pvalue_<name>/effect_size_<name> (permutation test of whether
350
+ mean same-subject similarity beats chance pairing), and
351
+ wc_confound_r_<name>/wc_confound_p_<name> (does combined word
352
+ count predict similarity — a possible "more words = more
353
+ similar" confound). The test-level values (p-value, effect
354
+ size, confound r) are the same for every row, same as
355
+ null_mean_/null_std_ already were — one number per pair, not
356
+ per subject.
357
+ model_name : str
358
+ n_null : int
359
+ rng_seed : int or None
360
+ file_glob : str
361
+ min_words : int
362
+ Fields with fewer words than this get flagged (not dropped) —
363
+ see flag_low_quality.
364
+
365
+ Returns
366
+ -------
367
+ pandas.DataFrame
368
+ """
369
+ df = load_field_study_folder(folder_path, text_fields, file_glob=file_glob)
370
+ df = flag_low_quality(df, text_fields, min_words=min_words)
371
+
372
+ # Only embed fields actually referenced by delta_pair/paired_fields —
373
+ # embedding is the expensive step, no reason to run it over fields
374
+ # nobody's comparing.
375
+ fields_to_embed = set()
376
+ if delta_pair is not None:
377
+ fields_to_embed.update(delta_pair)
378
+ for field_a, field_b, _ in (paired_fields or []):
379
+ fields_to_embed.update([field_a, field_b])
380
+ embeddings = embed_text_fields(df, sorted(fields_to_embed), model_name=model_name)
381
+
382
+ if delta_pair is not None:
383
+ field_from, field_to = delta_pair
384
+ _, magnitude = compute_delta_vector(embeddings, field_from, field_to)
385
+ df["delta_magnitude"] = magnitude
386
+
387
+ if paired_fields:
388
+ results = compute_paired_similarity(embeddings, paired_fields, n_null=n_null, rng_seed=rng_seed)
389
+ for name, result in results.items():
390
+ df[f"sim_{name}"] = result["same_subject_similarity"]
391
+ df[f"null_mean_{name}"] = result["null_mean"]
392
+ df[f"null_std_{name}"] = result["null_std"]
393
+
394
+ test = permutation_test_similarity(
395
+ result["same_subject_similarity"], result["null_shuffle_means"]
396
+ )
397
+ df[f"pvalue_{name}"] = test["p_value"]
398
+ df[f"effect_size_{name}"] = test["effect_size"]
399
+
400
+ confound = wordcount_confound_check(df, paired_fields)
401
+ for name, result in confound.items():
402
+ df[f"wc_confound_r_{name}"] = result["r"]
403
+ df[f"wc_confound_p_{name}"] = result["p_value"]
404
+
405
+ return df
@@ -13,6 +13,8 @@ Data processing and analysis library backing [PhysicsAnalysis](https://github.co
13
13
  - Generic **Excel / CSV / TSV / plain text**, with automatic sub-table detection for side-by-side data layouts on one sheet
14
14
  - **Processes signals** — bleach correction, denoising, Z-score PETH slicing, FFT with peak annotation, slope/segment analysis
15
15
  - **Fits curves** — linear, single/double exponential, exponential rise, Gaussian, sinusoidal, and a photon-entanglement visibility model, all via `scipy.optimize.curve_fit`
16
+ - **Analyses text field studies** — one JSON file per subject with several free-text fields; pick any pair of fields to compare directly (e.g. does the answer to one question track another for the same subject). Word counts, data-quality flagging, sentence-transformers embeddings, an optional delta-vector magnitude between two fields, and an optional paired-similarity metric per pair with a permutation test and a word-count confound check. Domain-agnostic — field names and which pairs to compare are supplied by the caller, nothing is hardcoded to one study
17
+ - **Validates the similarity metric statistically** — Benjamini-Hochberg FDR-corrected permutation-test p-values, Cohen's d effect size, a word-count-controlled OLS regression (statsmodels), a bootstrap confidence interval on the mean, and a leave-one-out sensitivity check, one row per field pair, with docstrings explaining what each statistic means
16
18
 
17
19
  ---
18
20
 
@@ -27,6 +29,8 @@ PhysicsLibrary/
27
29
  processing_TDT.py TDT tank reading, bleach correction, denoising, event markers
28
30
  analysis.py PETH/Z-score, FFT, slope segments, curve-fit runner
29
31
  models.py Parametric model functions for curve fitting
32
+ text_field_study.py Grouped-text-field study pipeline (embeddings, delta vector, paired similarity)
33
+ field_study_validation.py Statistical validation for the paired-similarity metric (permutation test, Cohen's d, regression, bootstrap CI, leave-one-out)
30
34
  loaders/
31
35
  tdt_loader.py Wraps processing_TDT into a Dataset
32
36
  oxysoft_loader.py Oxysoft .txt parsing (folder + single-file) into a Dataset
@@ -52,7 +56,8 @@ git+https://github.com/zakgm2/PhysicsLibrary.git
52
56
  ### Requirements
53
57
 
54
58
  - Python 3.10+
55
- - `numpy`, `scipy`, `tdt` (installed automatically)
59
+ - `numpy`, `scipy`, `tdt`, `pandas`, `sentence-transformers`, `statsmodels` (installed automatically)
60
+ - `sentence-transformers` pulls in `torch`/`transformers` as transitive dependencies — a genuinely heavy install (hundreds of MB) if you only need the signal-processing side; only actually loaded when you call `embed_text_fields`/`run_field_study_pipeline`
56
61
  - `openpyxl` — only needed for `.xlsx`/`.xls` files; imported lazily with a clear error if missing when you actually try to load Excel
57
62
 
58
63
  ---
@@ -100,6 +105,34 @@ result = pl.fit_model_to_segment(x_seg, y_seg, pl.single_exponential_model, p0_f
100
105
  result["popt"], result["r2"], result["y_fit"]
101
106
  ```
102
107
 
108
+ ```python
109
+ # Text field study — one JSON file per subject, e.g. P-0001.json. Pick
110
+ # pairs of fields to compare directly; no grouping concept needed.
111
+ fields = pl.peek_fields(folder_path) # see what fields exist before picking pairs
112
+ df = pl.run_field_study_pipeline(
113
+ folder_path,
114
+ text_fields=["q1", "q2", "q3", "q4"],
115
+ delta_pair=("q1", "q2"), # optional: how much did q2 change from q1
116
+ paired_fields=[("q1", "q3", "pair1")], # optional: does q1 track q3
117
+ )
118
+ # df has one row per subject: wordcount_<field>, low_quality_<field>, delta_magnitude,
119
+ # sim_<pair>, pvalue_<pair>, effect_size_<pair>, wc_confound_r_<pair>, ...
120
+ ```
121
+
122
+ ```python
123
+ # Statistical validation of the paired-similarity metric — one row per pair
124
+ summary = pl.run_validation_pipeline(
125
+ folder_path,
126
+ text_fields=["q1", "q3"],
127
+ paired_fields=[("q1", "q3", "pair1")],
128
+ )
129
+ # summary: p_value, p_value_fdr, cohens_d, wc_coef_a/b + wc_pvalue_a/b,
130
+ # regression_r_squared, ci_lower/ci_upper, n_flagged_loo, flagged_participant_ids
131
+ ```
132
+
133
+ See [FIELD_STUDY_METHODOLOGY.md](FIELD_STUDY_METHODOLOGY.md) for why each statistic in the
134
+ validation step is a sound, standard technique — useful if anyone asks.
135
+
103
136
  See [CHANGELOG.md](CHANGELOG.md) for the version history.
104
137
 
105
138
  ---
@@ -110,8 +143,10 @@ Everything importable from `PhysicsLibrary` directly:
110
143
 
111
144
  | Category | Names |
112
145
  |----------|-------|
113
- | Format detection | `choose_file`, `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
146
+ | Format detection | `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
114
147
  | Loading | `load_dataset`, `load_dataset_file`, `load_any_file`, `load_pt2` |
115
148
  | TDT processing | `process_tdt_folder`, `validate_tdt_folder`, `get_tdt_struct`, `get_plot_data`, `correct_bleaching`, `denoise_signal`, `get_event_markers` |
116
149
  | Analysis | `get_zscore_slice`, `smooth_signal`, `bin_for_heatmap`, `compute_fft_slice`, `annotate_fft_peaks`, `compute_slope_segment`, `fit_model_to_segment` |
117
150
  | Curve fit models | `linear_model`, `single_exponential_model`, `exponential_rise_model`, `double_exponential_model`, `gaussian_model`, `sinusoidal_model`, `visibility_model` |
151
+ | Text field study | `run_field_study_pipeline`, `load_field_study_folder`, `peek_fields`, `flag_low_quality`, `embed_text_fields`, `compute_delta_vector`, `compute_paired_similarity`, `permutation_test_similarity`, `wordcount_confound_check` |
152
+ | Field study validation | `run_validation_pipeline`, `build_validation_summary`, `cohens_d`, `benjamini_hochberg`, `wordcount_controlled_regression`, `bootstrap_mean_ci`, `leave_one_out_sensitivity` |
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ZaksPhysicsLibrary
3
- Version: 1.3.0
4
- Summary: Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data
3
+ Version: 1.5.0
4
+ Summary: Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies
5
5
  Author: zakgm2
6
6
  License-Expression: MIT
7
7
  Project-URL: Homepage, https://github.com/zakgm2/PhysicsLibrary
8
8
  Project-URL: Repository, https://github.com/zakgm2/PhysicsLibrary
9
9
  Project-URL: Changelog, https://github.com/zakgm2/PhysicsLibrary/blob/main/CHANGELOG.md
10
- Keywords: physics,neuroscience,fibre-photometry,NIRS,TDT,EFNMR,signal-processing
10
+ Keywords: physics,neuroscience,fibre-photometry,NIRS,TDT,EFNMR,signal-processing,text-analysis,nlp
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Programming Language :: Python :: 3.10
13
13
  Classifier: Programming Language :: Python :: 3.11
@@ -21,6 +21,9 @@ License-File: LICENSE
21
21
  Requires-Dist: numpy
22
22
  Requires-Dist: scipy
23
23
  Requires-Dist: tdt
24
+ Requires-Dist: pandas
25
+ Requires-Dist: sentence-transformers
26
+ Requires-Dist: statsmodels
24
27
  Dynamic: license-file
25
28
 
26
29
  # PhysicsLibrary
@@ -38,6 +41,8 @@ Data processing and analysis library backing [PhysicsAnalysis](https://github.co
38
41
  - Generic **Excel / CSV / TSV / plain text**, with automatic sub-table detection for side-by-side data layouts on one sheet
39
42
  - **Processes signals** — bleach correction, denoising, Z-score PETH slicing, FFT with peak annotation, slope/segment analysis
40
43
  - **Fits curves** — linear, single/double exponential, exponential rise, Gaussian, sinusoidal, and a photon-entanglement visibility model, all via `scipy.optimize.curve_fit`
44
+ - **Analyses text field studies** — one JSON file per subject with several free-text fields; pick any pair of fields to compare directly (e.g. does the answer to one question track another for the same subject). Word counts, data-quality flagging, sentence-transformers embeddings, an optional delta-vector magnitude between two fields, and an optional paired-similarity metric per pair with a permutation test and a word-count confound check. Domain-agnostic — field names and which pairs to compare are supplied by the caller, nothing is hardcoded to one study
45
+ - **Validates the similarity metric statistically** — Benjamini-Hochberg FDR-corrected permutation-test p-values, Cohen's d effect size, a word-count-controlled OLS regression (statsmodels), a bootstrap confidence interval on the mean, and a leave-one-out sensitivity check, one row per field pair, with docstrings explaining what each statistic means
41
46
 
42
47
  ---
43
48
 
@@ -52,6 +57,8 @@ PhysicsLibrary/
52
57
  processing_TDT.py TDT tank reading, bleach correction, denoising, event markers
53
58
  analysis.py PETH/Z-score, FFT, slope segments, curve-fit runner
54
59
  models.py Parametric model functions for curve fitting
60
+ text_field_study.py Grouped-text-field study pipeline (embeddings, delta vector, paired similarity)
61
+ field_study_validation.py Statistical validation for the paired-similarity metric (permutation test, Cohen's d, regression, bootstrap CI, leave-one-out)
55
62
  loaders/
56
63
  tdt_loader.py Wraps processing_TDT into a Dataset
57
64
  oxysoft_loader.py Oxysoft .txt parsing (folder + single-file) into a Dataset
@@ -77,7 +84,8 @@ git+https://github.com/zakgm2/PhysicsLibrary.git
77
84
  ### Requirements
78
85
 
79
86
  - Python 3.10+
80
- - `numpy`, `scipy`, `tdt` (installed automatically)
87
+ - `numpy`, `scipy`, `tdt`, `pandas`, `sentence-transformers`, `statsmodels` (installed automatically)
88
+ - `sentence-transformers` pulls in `torch`/`transformers` as transitive dependencies — a genuinely heavy install (hundreds of MB) if you only need the signal-processing side; only actually loaded when you call `embed_text_fields`/`run_field_study_pipeline`
81
89
  - `openpyxl` — only needed for `.xlsx`/`.xls` files; imported lazily with a clear error if missing when you actually try to load Excel
82
90
 
83
91
  ---
@@ -125,6 +133,34 @@ result = pl.fit_model_to_segment(x_seg, y_seg, pl.single_exponential_model, p0_f
125
133
  result["popt"], result["r2"], result["y_fit"]
126
134
  ```
127
135
 
136
+ ```python
137
+ # Text field study — one JSON file per subject, e.g. P-0001.json. Pick
138
+ # pairs of fields to compare directly; no grouping concept needed.
139
+ fields = pl.peek_fields(folder_path) # see what fields exist before picking pairs
140
+ df = pl.run_field_study_pipeline(
141
+ folder_path,
142
+ text_fields=["q1", "q2", "q3", "q4"],
143
+ delta_pair=("q1", "q2"), # optional: how much did q2 change from q1
144
+ paired_fields=[("q1", "q3", "pair1")], # optional: does q1 track q3
145
+ )
146
+ # df has one row per subject: wordcount_<field>, low_quality_<field>, delta_magnitude,
147
+ # sim_<pair>, pvalue_<pair>, effect_size_<pair>, wc_confound_r_<pair>, ...
148
+ ```
149
+
150
+ ```python
151
+ # Statistical validation of the paired-similarity metric — one row per pair
152
+ summary = pl.run_validation_pipeline(
153
+ folder_path,
154
+ text_fields=["q1", "q3"],
155
+ paired_fields=[("q1", "q3", "pair1")],
156
+ )
157
+ # summary: p_value, p_value_fdr, cohens_d, wc_coef_a/b + wc_pvalue_a/b,
158
+ # regression_r_squared, ci_lower/ci_upper, n_flagged_loo, flagged_participant_ids
159
+ ```
160
+
161
+ See [FIELD_STUDY_METHODOLOGY.md](FIELD_STUDY_METHODOLOGY.md) for why each statistic in the
162
+ validation step is a sound, standard technique — useful if anyone asks.
163
+
128
164
  See [CHANGELOG.md](CHANGELOG.md) for the version history.
129
165
 
130
166
  ---
@@ -135,8 +171,10 @@ Everything importable from `PhysicsLibrary` directly:
135
171
 
136
172
  | Category | Names |
137
173
  |----------|-------|
138
- | Format detection | `choose_file`, `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
174
+ | Format detection | `detect_format`, `detect_format_file`, `DataFormat`, `Dataset` |
139
175
  | Loading | `load_dataset`, `load_dataset_file`, `load_any_file`, `load_pt2` |
140
176
  | TDT processing | `process_tdt_folder`, `validate_tdt_folder`, `get_tdt_struct`, `get_plot_data`, `correct_bleaching`, `denoise_signal`, `get_event_markers` |
141
177
  | Analysis | `get_zscore_slice`, `smooth_signal`, `bin_for_heatmap`, `compute_fft_slice`, `annotate_fft_peaks`, `compute_slope_segment`, `fit_model_to_segment` |
142
178
  | Curve fit models | `linear_model`, `single_exponential_model`, `exponential_rise_model`, `double_exponential_model`, `gaussian_model`, `sinusoidal_model`, `visibility_model` |
179
+ | Text field study | `run_field_study_pipeline`, `load_field_study_folder`, `peek_fields`, `flag_low_quality`, `embed_text_fields`, `compute_delta_vector`, `compute_paired_similarity`, `permutation_test_similarity`, `wordcount_confound_check` |
180
+ | Field study validation | `run_validation_pipeline`, `build_validation_summary`, `cohens_d`, `benjamini_hochberg`, `wordcount_controlled_regression`, `bootstrap_mean_ci`, `leave_one_out_sensitivity` |
@@ -4,10 +4,12 @@ pyproject.toml
4
4
  PhysicsLibrary/__init__.py
5
5
  PhysicsLibrary/analysis.py
6
6
  PhysicsLibrary/dataset.py
7
+ PhysicsLibrary/field_study_validation.py
7
8
  PhysicsLibrary/file_parser.py
8
9
  PhysicsLibrary/file_parser_generic.py
9
10
  PhysicsLibrary/models.py
10
11
  PhysicsLibrary/processing_TDT.py
12
+ PhysicsLibrary/text_field_study.py
11
13
  PhysicsLibrary/loaders/__init__.py
12
14
  PhysicsLibrary/loaders/oxysoft_loader.py
13
15
  PhysicsLibrary/loaders/pt2_loader.py
@@ -0,0 +1,6 @@
1
+ numpy
2
+ scipy
3
+ tdt
4
+ pandas
5
+ sentence-transformers
6
+ statsmodels
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ZaksPhysicsLibrary"
7
- version = "1.3.0"
8
- description = "Data processing and analysis library for TDT, Oxysoft NIRS, and Terranova EFNMR lab data"
7
+ version = "1.5.0"
8
+ description = "Data processing and analysis library for TDT, Oxysoft NIRS, Terranova EFNMR lab data, and grouped-text-field studies"
9
9
  requires-python = ">=3.10"
10
10
  readme = "README.md"
11
11
  license = "MIT"
@@ -13,7 +13,7 @@ license-files = ["LICENSE"]
13
13
  authors = [
14
14
  { name = "zakgm2" },
15
15
  ]
16
- keywords = ["physics", "neuroscience", "fibre-photometry", "NIRS", "TDT", "EFNMR", "signal-processing"]
16
+ keywords = ["physics", "neuroscience", "fibre-photometry", "NIRS", "TDT", "EFNMR", "signal-processing", "text-analysis", "nlp"]
17
17
  classifiers = [
18
18
  "Programming Language :: Python :: 3",
19
19
  "Programming Language :: Python :: 3.10",
@@ -27,6 +27,9 @@ dependencies = [
27
27
  "numpy",
28
28
  "scipy",
29
29
  "tdt",
30
+ "pandas",
31
+ "sentence-transformers",
32
+ "statsmodels",
30
33
  ]
31
34
 
32
35
  [project.urls]
@@ -1,3 +0,0 @@
1
- numpy
2
- scipy
3
- tdt