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.
- {zaksphysicslibrary-1.3.0/ZaksPhysicsLibrary.egg-info → zaksphysicslibrary-1.5.0}/PKG-INFO +43 -5
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/__init__.py +22 -0
- zaksphysicslibrary-1.5.0/PhysicsLibrary/field_study_validation.py +433 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/processing_TDT.py +28 -4
- zaksphysicslibrary-1.5.0/PhysicsLibrary/text_field_study.py +405 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/README.md +37 -2
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0/ZaksPhysicsLibrary.egg-info}/PKG-INFO +43 -5
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt +2 -0
- zaksphysicslibrary-1.5.0/ZaksPhysicsLibrary.egg-info/requires.txt +6 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/pyproject.toml +6 -3
- zaksphysicslibrary-1.3.0/ZaksPhysicsLibrary.egg-info/requires.txt +0 -3
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/LICENSE +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/analysis.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/dataset.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/file_parser.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/file_parser_generic.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/__init__.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/oxysoft_loader.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/pt2_loader.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/tdt_loader.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/models.py +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/dependency_links.txt +0 -0
- {zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/top_level.txt +0 -0
- {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.
|
|
4
|
-
Summary: Data processing and analysis library for TDT, Oxysoft NIRS,
|
|
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 | `
|
|
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),
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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 | `
|
|
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.
|
|
4
|
-
Summary: Data processing and analysis library for TDT, Oxysoft NIRS,
|
|
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 | `
|
|
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` |
|
{zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
|
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ZaksPhysicsLibrary"
|
|
7
|
-
version = "1.
|
|
8
|
-
description = "Data processing and analysis library for TDT, Oxysoft NIRS,
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/PhysicsLibrary/loaders/oxysoft_loader.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{zaksphysicslibrary-1.3.0 → zaksphysicslibrary-1.5.0}/ZaksPhysicsLibrary.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|