vsn 0.1.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.
vsn-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,469 @@
1
+ Metadata-Version: 2.4
2
+ Name: vsn
3
+ Version: 0.1.0
4
+ Summary: Pure Python implementation of Variance Stabilization and Normalization
5
+ Project-URL: Repository, https://github.com/animesh/vsn
6
+ Project-URL: Issues, https://github.com/animesh/vsn/issues
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: numpy>=1.23
10
+ Requires-Dist: scipy>=1.9
11
+
12
+ # VSN in Python
13
+
14
+ A NumPy/SciPy implementation of variance stabilization and normalization (VSN), based on the Bioconductor `vsn` package by Wolfgang Huber and contributors.
15
+
16
+ The implementation fits an additive-multiplicative error model, estimates sample-specific calibration parameters, applies the generalized logarithm, and uses the same robust least-trimmed-squares workflow as the R implementation.
17
+
18
+ The corrected implementation reproduces the supplied R `vsn::justvsn()` reference values to floating-point precision on the validated sparse seven-sample dataset.
19
+
20
+ ## Project components
21
+
22
+ | File | Purpose |
23
+ |---|---|
24
+ | `vsn2.py` | Reusable Python VSN implementation |
25
+ | `vsn2mo.py` | Interactive Marimo normalization dashboard |
26
+ | `run.py` | Run VSN on selected columns of a CSV or TSV matrix |
27
+ | `compare.py` | Compare saved Python VSN output with R output |
28
+ | `run_and_compare.py` | Select matching samples, run Python VSN, save output, and compare with R |
29
+ | `run.qmd` | Quarto Shiny dashboard using the R `vsn` package |
30
+
31
+ ## Requirements
32
+
33
+ For `vsn2.py`:
34
+
35
+ ```text
36
+ numpy
37
+ scipy
38
+ ```
39
+
40
+ Install with:
41
+
42
+ ```bash
43
+ python -m pip install numpy scipy
44
+ ```
45
+
46
+ The matrix runner and comparison utilities also require pandas:
47
+
48
+ ```bash
49
+ python -m pip install numpy scipy pandas
50
+ ```
51
+
52
+ The Marimo dashboard declares its dependencies inline and can be run with `uv`.
53
+
54
+ ## Quick start
55
+
56
+ ```python
57
+ import numpy as np
58
+ from vsn2 import vsn_matrix
59
+
60
+ x = np.array(
61
+ [
62
+ [1200.0, 1500.0, np.nan],
63
+ [2500.0, 2300.0, 2700.0],
64
+ [5000.0, np.nan, 4800.0],
65
+ [9000.0, 8700.0, 9200.0],
66
+ ]
67
+ )
68
+
69
+ result = vsn_matrix(
70
+ x,
71
+ min_data_points_per_stratum=0,
72
+ )
73
+
74
+ transformed = result.hx
75
+ ```
76
+
77
+ For ordinary datasets, retain the default `min_data_points_per_stratum=42`. The lower value above is only required for very small examples.
78
+
79
+ ## Input and missing values
80
+
81
+ The input must be a floating-point matrix with:
82
+
83
+ - rows representing features, proteins, or probes
84
+ - columns representing samples
85
+ - missing observations represented by `np.nan`
86
+
87
+ The core `vsn_matrix()` function does not automatically interpret zero as missing. Convert zeros before fitting when zero represents an undetected intensity:
88
+
89
+ ```python
90
+ x = np.asarray(x, dtype=float)
91
+ x[x == 0] = np.nan
92
+ ```
93
+
94
+ Rows that are entirely missing across the selected samples are excluded from model fitting. Their positions are retained, and their transformed output remains entirely `np.nan`.
95
+
96
+ Rows that are only partially missing are retained. Every finite input value receives a VSN-transformed value, while missing positions remain missing.
97
+
98
+ ## Transformation
99
+
100
+ For sample `j` and feature `i`, the fitted natural-scale transformation is:
101
+
102
+ ```text
103
+ h_ij = asinh(exp(log_b_j) * x_ij + a_j)
104
+ ```
105
+
106
+ The returned standard VSN output is:
107
+
108
+ ```text
109
+ hx_ij = h_ij / log(2) - hoffset
110
+ ```
111
+
112
+ where:
113
+
114
+ ```text
115
+ hoffset = log2(2 * exp(mean(log_b)))
116
+ ```
117
+
118
+ For affine calibration, each sample has its own offset `a_j` and log-scale `log_b_j`.
119
+
120
+ ## Model fitting
121
+
122
+ The implementation uses:
123
+
124
+ - profile maximum likelihood
125
+ - analytical gradients matching the R/C likelihood
126
+ - L-BFGS-B optimization through `scipy.optimize.minimize`
127
+ - robust least-trimmed-squares iterations
128
+ - five intensity slices per LTS iteration
129
+ - R-compatible quantile and missing-value behavior
130
+ - R-compatible starting parameters and final `hoffset`
131
+
132
+ ### Default optimizer parameters
133
+
134
+ ```python
135
+ DEFAULT_OPTIMPAR = {
136
+ "factr": 5e7,
137
+ "pgtol": 2e-4,
138
+ "maxit": 60000,
139
+ "trace": 0,
140
+ "cvg_niter": 7,
141
+ "cvg_eps": 0.0,
142
+ }
143
+ ```
144
+
145
+ SciPy receives:
146
+
147
+ ```text
148
+ maxcor = 5
149
+ ftol = factr * machine_epsilon
150
+ gtol = pgtol
151
+ maxiter = maxit
152
+ ```
153
+
154
+ Offsets are unbounded. Log-scale parameters are bounded to `[-100, 100]`.
155
+
156
+ ## Correct R-compatible LTS partitioning
157
+
158
+ The important compatibility correction is the implementation of:
159
+
160
+ ```r
161
+ cut(rank(hmean, na.last = TRUE), breaks = 5)
162
+ ```
163
+
164
+ When R receives a scalar number of breaks, R first creates equally spaced internal boundaries over the original rank range. R then expands only the first and last endpoints by 0.1% of that range.
165
+
166
+ The matching Python implementation is:
167
+
168
+ ```python
169
+ cut_breaks = np.linspace(rank_min, rank_max, n_slices + 1)
170
+ cut_breaks[0] -= rank_span * 0.001
171
+ cut_breaks[-1] += rank_span * 0.001
172
+
173
+ slice_labels = (
174
+ np.searchsorted(
175
+ cut_breaks,
176
+ rank_hmean,
177
+ side="left",
178
+ )
179
+ - 1
180
+ )
181
+ slice_labels = np.clip(slice_labels, 0, n_slices - 1)
182
+ ```
183
+
184
+ The earlier implementation expanded the complete range before generating all boundaries. That shifted every internal boundary and changed which sparse rows entered the LTS fit.
185
+
186
+ In the validated sparse dataset:
187
+
188
+ ```text
189
+ Rows in input: 7,816
190
+ Samples: 7
191
+ Finite values compared: 18,023
192
+ Old Python RMSE versus R: 0.002289762308143096
193
+ Corrected Python RMSE versus R: 1.5963303695428504e-15
194
+ Corrected maximum error: 1.0658141036401503e-14
195
+ ```
196
+
197
+ The corrected differences are at floating-point precision.
198
+
199
+ ## Missing values during LTS selection
200
+
201
+ Row means are calculated from available transformed values.
202
+
203
+ Residual variance is calculated with missing values propagated, matching R:
204
+
205
+ ```python
206
+ squared_residuals = (hy - hmean[:, np.newaxis]) ** 2
207
+ rvar = np.sum(squared_residuals, axis=1)
208
+ ```
209
+
210
+ This intentionally does not use `np.nansum()`.
211
+
212
+ A partially missing row therefore has:
213
+
214
+ ```text
215
+ finite transformed values
216
+ finite row mean
217
+ finite rank and intensity slice
218
+ NaN residual variance
219
+ ```
220
+
221
+ Such a row is normally omitted from later trimmed fitting iterations. Rows in the lowest-intensity slice are retained by the explicit R-compatible selection rule. Regardless of LTS selection, the final fitted transformation is applied to every finite input value.
222
+
223
+ ## API
224
+
225
+ ### `vsn_matrix()`
226
+
227
+ ```python
228
+ vsn_matrix(
229
+ x,
230
+ reference=None,
231
+ strata=None,
232
+ lts_quantile=0.9,
233
+ subsample=0,
234
+ verbose=False,
235
+ return_data=True,
236
+ calib="affine",
237
+ pstart=None,
238
+ min_data_points_per_stratum=42,
239
+ optimpar=None,
240
+ defaultpar=None,
241
+ )
242
+ ```
243
+
244
+ Parameters:
245
+
246
+ - `x`: two-dimensional NumPy array, with features in rows and samples in columns
247
+ - `reference`: optional fitted `VsnResult` used for reference normalization
248
+ - `strata`: optional one-based integer labels for row strata
249
+ - `lts_quantile`: retained LTS fraction, between `0.5` and `1.0`
250
+ - `subsample`: number of rows sampled per stratum; `0` uses all rows
251
+ - `verbose`: print fitting diagnostics
252
+ - `return_data`: calculate and store the transformed matrix in `result.hx`
253
+ - `calib`: `"affine"` or `"none"`
254
+ - `pstart`: optional custom starting coefficients
255
+ - `min_data_points_per_stratum`: minimum number of rows required per stratum
256
+ - `optimpar`: overrides selected optimizer settings
257
+ - `defaultpar`: overrides the default optimizer dictionary
258
+
259
+ ### `VsnResult`
260
+
261
+ The result contains:
262
+
263
+ - `hx`: transformed matrix when `return_data=True`
264
+ - `coefficients`: fitted offsets and log-scales
265
+ - `mu`: transformed row means
266
+ - `sigsq`: estimated residual variance
267
+ - `hoffset`: final stratum-specific VSN offset
268
+ - `strata`: row stratum labels
269
+ - `lbfgsb`: optimizer status code; zero indicates success
270
+ - `calib`: calibration mode
271
+
272
+ Coefficient layout:
273
+
274
+ ```python
275
+ offsets = result.coefficients[:, :, 0]
276
+ log_scales = result.coefficients[:, :, 1]
277
+ scales = np.exp(log_scales)
278
+ ```
279
+
280
+ ### Direct transformation
281
+
282
+ ```python
283
+ import numpy as np
284
+ from vsn2 import vsn2_trsf
285
+
286
+ transformed = vsn2_trsf(
287
+ x=x,
288
+ p=result.coefficients,
289
+ strata=np.ones(x.shape[0], dtype=int),
290
+ hoffset=result.hoffset,
291
+ calib="affine",
292
+ )
293
+ ```
294
+
295
+ ## Optimizer overrides
296
+
297
+ ```python
298
+ result = vsn_matrix(
299
+ x,
300
+ optimpar={
301
+ "factr": 5e7,
302
+ "pgtol": 2e-4,
303
+ "maxit": 60000,
304
+ "trace": 0,
305
+ "cvg_niter": 7,
306
+ "cvg_eps": 0.0,
307
+ },
308
+ )
309
+ ```
310
+
311
+ Python uses underscores in `cvg_niter` and `cvg_eps`, corresponding to R's `cvg.niter` and `cvg.eps`.
312
+
313
+ ## Matrix runner
314
+
315
+ Run VSN on explicitly named columns:
316
+
317
+ ```bash
318
+ python run.py input.tsv output.tsv \
319
+ --id-columns "Protein.Group,Protein.Names,Genes" \
320
+ --intensity-columns "Sample1,Sample2,Sample3,Sample4"
321
+ ```
322
+
323
+ Select intensity columns with regular expressions:
324
+
325
+ ```bash
326
+ python run.py input.tsv output.tsv \
327
+ --id-columns "Protein.Group,Protein.Names,Genes" \
328
+ --intensity-regex "^F:"
329
+ ```
330
+
331
+ The runner:
332
+
333
+ - reads CSV or TSV input
334
+ - preserves the requested identifier columns
335
+ - converts nonnumeric and nonfinite intensity entries to missing values
336
+ - treats zero as missing by default
337
+ - preserves finite values in partially missing rows
338
+ - writes VSN-transformed values
339
+ - writes fitted parameters to a separate CSV file
340
+
341
+ Use `--keep-zero` only when zero is a genuine measured intensity.
342
+
343
+ ## Comparing with R
344
+
345
+ Compare a saved Python result with R output:
346
+
347
+ ```bash
348
+ python compare.py python_vsn.tsv r_vsn.csv
349
+ ```
350
+
351
+ Optional outputs:
352
+
353
+ ```bash
354
+ python compare.py python_vsn.tsv r_vsn.csv \
355
+ --details-output comparison_by_sample.csv \
356
+ --differences-output differences_by_row.csv
357
+ ```
358
+
359
+ The comparison reports:
360
+
361
+ - matched intensity columns
362
+ - number of finite paired values
363
+ - RMSE
364
+ - mean absolute error
365
+ - mean Python-minus-R difference
366
+ - maximum absolute error
367
+ - per-sample Pearson correlation
368
+ - per-sample R-squared
369
+ - missing-value counts
370
+
371
+ Both fits must use the same input samples and rows for a meaningful numerical comparison. Fitting Python on all samples and comparing only a subset with an R fit made on that subset is not an equivalent test.
372
+
373
+ ## Generate and compare in one command
374
+
375
+ ```bash
376
+ python run_and_compare.py raw_matrix.tsv r_vsn.csv \
377
+ --python-output python_vsn_matched_samples.tsv
378
+ ```
379
+
380
+ This workflow:
381
+
382
+ - identifies samples represented in the R output
383
+ - extracts the matching raw columns
384
+ - fits Python VSN on exactly those samples
385
+ - saves the Python-transformed matrix
386
+ - saves fitted parameters
387
+ - compares Python and R values
388
+
389
+ ## Marimo dashboard
390
+
391
+ Run locally:
392
+
393
+ ```bash
394
+ uv run marimo run vsn2mo.py
395
+ ```
396
+
397
+ The dashboard supports:
398
+
399
+ - CSV and TSV uploads
400
+ - intensity-column prefix selection
401
+ - editable VSN and optimizer parameters
402
+ - raw and transformed diagnostics
403
+ - timestamped result columns when output names already exist
404
+ - parameterized download filenames
405
+
406
+ The Marimo core contains the same corrected R-compatible rank-slice partitioning as `vsn2.py`.
407
+
408
+ ## Quarto Shiny dashboard
409
+
410
+ Run locally:
411
+
412
+ ```bash
413
+ quarto preview run.qmd
414
+ ```
415
+
416
+ The dashboard uses the R `vsn` package directly and exposes:
417
+
418
+ - calibration mode
419
+ - LTS quantile
420
+ - subsampling
421
+ - minimum rows per stratum
422
+ - `factr`, `pgtol`, and maximum iterations
423
+ - optimizer trace level
424
+ - maximum LTS iterations
425
+ - LTS convergence tolerance
426
+
427
+ It also provides raw and transformed mean-SD plots, distributions, sample boxplots, regression diagnostics, timestamp-safe result columns, and parameterized downloads.
428
+
429
+ ## R reference workflow
430
+
431
+ The equivalent R normalization is:
432
+
433
+ ```r
434
+ library(vsn)
435
+
436
+ x <- as.matrix(data[, intensity_columns, drop = FALSE])
437
+ x[x == 0] <- NA_real_
438
+
439
+ normalized <- vsn::justvsn(
440
+ x,
441
+ minDataPointsPerStratum = 0
442
+ )
443
+ ```
444
+
445
+ For ordinary datasets, retain the package default minimum unless a lower threshold is intentional.
446
+
447
+ ## References
448
+
449
+ Huber W, von Heydebreck A, Sültmann H, Poustka A, Vingron M. Variance stabilization applied to microarray data calibration and to the quantification of differential expression. Bioinformatics. 2002;18(Suppl 1):S96-S104.
450
+
451
+ Bioconductor package: `vsn`, by Wolfgang Huber and contributors.
452
+
453
+ ## Current status
454
+
455
+ The following components have been independently checked in the current implementation:
456
+
457
+ - profile negative log-likelihood
458
+ - analytical gradient
459
+ - affine parameterization
460
+ - R-compatible starting parameters
461
+ - R-compatible `hoffset`
462
+ - L-BFGS-B memory setting
463
+ - missing-value propagation during LTS residual selection
464
+ - R-compatible quantile interpolation
465
+ - R-compatible five-slice rank partitioning
466
+ - restoration of all-missing rows in the final output
467
+ - application of the final transformation to partially observed rows
468
+
469
+ The formerly documented residual RMSE values of approximately `0.000432` and `0.00229` are not irreducible differences. The sparse-data discrepancy was caused by incorrect internal `cut()` boundaries and is fixed in the current `vsn2.py` and `vsn2mo.py`.