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 +469 -0
- vsn-0.1.0/README.md +458 -0
- vsn-0.1.0/pyproject.toml +21 -0
- vsn-0.1.0/setup.cfg +4 -0
- vsn-0.1.0/vsn.egg-info/PKG-INFO +469 -0
- vsn-0.1.0/vsn.egg-info/SOURCES.txt +8 -0
- vsn-0.1.0/vsn.egg-info/dependency_links.txt +1 -0
- vsn-0.1.0/vsn.egg-info/requires.txt +2 -0
- vsn-0.1.0/vsn.egg-info/top_level.txt +1 -0
- vsn-0.1.0/vsn.py +1145 -0
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`.
|