Skip to content

Commit 663cc47

Browse files
adRn-sclaude
andauthored
feat: lightweight PCA backend (scipy/numpy SVD) (#1431)
* Compute PCA with scipy instead of scikit-learn Replace the sklearn PCA and StandardScaler in Correlation.plot_pca with a scipy/numpy SVD implementation, reproducing sklearn's output (column standardization with population std, deterministic svd_flip sign convention, explained_variance_ from singular values). Drop the now-unused pandas and scikit-learn dependencies. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Fix transpose, log2/rowCenter, and small-ntop bugs in plot_pca; add PCA tests The scipy/SVD PCA rewrite inherited three pre-existing plot_pca bugs from the sklearn version: - --transpose crashed with a shape mismatch (np.dot(m, Wt.T)); U*S already gives sample projections, so orient as (components, samples) via Wt.T. - --log2 / --rowCenter were no-ops: they mutated self.matrix after m had been copied during ntop filtering. Now applied to a float copy before variance filtering. - --ntop below the sample count crashed the scatter with IndexError; guard with a clear sys.exit instead. Adds test_plotPCA.py coverage (sign-invariant coordinate/eigenvalue regressions, ntop behavior, transpose, CLI validation exits) that passes against both the sklearn and scipy implementations. * Make plotPCA coordinate test portable across BLAS backends test_plotPCA_default_coordinates failed on macOS CI: after PC1 the untransposed eigenvalues are near-degenerate, so the eigenvectors rotate freely and np.argpartition breaks top-ntop variance ties differently on Accelerate vs OpenBLAS, making per-feature coordinates non-reproducible. Replace it with test_plotPCA_default_eigenvalues, asserting only the portable eigenvalues; coordinate-level regression stays covered by the well-separated transpose case (test_plotPCA_transpose). --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ed84627 commit 663cc47

3 files changed

Lines changed: 265 additions & 32 deletions

File tree

pydeeptools/deeptools/correlation.py

Lines changed: 60 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@
1515
import matplotlib.markers
1616
import matplotlib.colors as pltcolors
1717
from deeptools.utilities import toString, convertCmap
18-
from sklearn.decomposition import PCA
19-
from sklearn.preprocessing import StandardScaler
18+
from scipy.linalg import svd
2019

2120
class Correlation:
2221
"""
@@ -454,48 +453,80 @@ def plot_pca(self, plot_filename=None, PCs=[1, 2], plot_title='', image_format=N
454453

455454
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(plotWidth, plotHeight), layout="constrained")
456455

457-
# Filter
458-
m = self.matrix
459-
rvs = m.var(axis=1)
460-
if self.transpose:
461-
m = m[np.nonzero(rvs)[0], :]
462-
rvs = rvs[np.nonzero(rvs)[0]]
463-
if self.ntop > 0 and m.shape[0] > self.ntop:
464-
m = m[np.argpartition(rvs, -self.ntop)[-self.ntop:], :]
465-
rvs = rvs[np.argpartition(rvs, -self.ntop)[-self.ntop:]]
456+
# Work on a float copy so the transforms below take effect and
457+
# self.matrix (which callers may reuse) is left untouched.
458+
m = self.matrix.astype(float, copy=True)
466459

467-
# log2 (if requested)
460+
# log2 (if requested). Applied before variance filtering so the
461+
# transform actually influences which rows are kept.
468462
if self.log2:
469-
self.matrix = np.log2(self.matrix + 0.01)
463+
m = np.log2(m + 0.01)
470464

471-
# Row center / transpose
465+
# Row center (incompatible with --transpose, enforced by the CLI).
472466
if self.rowCenter and not self.transpose:
473-
_ = self.matrix.mean(axis=1)
474-
self.matrix -= _[:, None]
475-
if self.transpose:
476-
m = m.T
467+
m -= m.mean(axis=1)[:, None]
477468

478-
# Center and scale
479-
scaler = StandardScaler()
480-
m2 = scaler.fit_transform(m)
469+
# Filter to the most variable rows (variance computed post-transform).
470+
rvs = m.var(axis=1)
471+
if self.transpose:
472+
nz = np.nonzero(rvs)[0]
473+
m = m[nz, :]
474+
rvs = rvs[nz]
475+
if self.ntop > 0 and m.shape[0] > self.ntop:
476+
idx = np.argpartition(rvs, -self.ntop)[-self.ntop:]
477+
m = m[idx, :]
478+
rvs = rvs[idx]
481479

482-
# PCA
483-
pca = PCA()
484-
Wt = pca.fit_transform(m2)
480+
if self.transpose:
481+
m = m.T
485482

486-
# % variance, eigenvalues
487-
variance = pca.explained_variance_ratio_
483+
# Center and scale each column to zero mean and unit variance
484+
# (equivalent to sklearn's StandardScaler, using population std/ddof=0).
485+
col_mean = m.mean(axis=0)
486+
col_std = m.std(axis=0)
487+
col_std[col_std == 0] = 1.0
488+
m2 = (m - col_mean) / col_std
489+
490+
# PCA via SVD of the (re-)centered matrix, mirroring sklearn's PCA().
491+
n_samples = m2.shape[0]
492+
X = m2 - m2.mean(axis=0)
493+
U, S, Vt = svd(X, full_matrices=False)
494+
495+
# Deterministic sign convention (sklearn's svd_flip, u_based_decision).
496+
max_abs_cols = np.argmax(np.abs(U), axis=0)
497+
signs = np.sign(U[max_abs_cols, range(U.shape[1])])
498+
U *= signs
499+
Vt *= signs[:, None]
500+
501+
# Projected coordinates: U * S == X @ V
502+
Wt = U * S
503+
504+
# Eigenvalues and % variance explained.
505+
eigenvalues = (S ** 2) / (n_samples - 1)
506+
variance = eigenvalues / eigenvalues.sum()
488507
pvar = variance / variance.sum()
489-
eigenvalues = pca.explained_variance_
490508

491509
if self.transpose:
492-
# Use the projected coordinates for the transposed matrix
493-
Wt = np.dot(m, Wt.T).T
510+
# With samples as observations, U * S already gives each sample's
511+
# projection onto the PCs (rows=samples, cols=components). Orient as
512+
# (components, samples) to match the indexing used below.
513+
Wt = Wt.T
494514

495515
if plot_filename is not None:
496516
n = n_bars = len(self.labels)
497517
if eigenvalues.size < n:
498518
n_bars = eigenvalues.size
519+
# The requested principal components must exist.
520+
if max(PCs) > eigenvalues.size:
521+
sys.exit("Cannot plot PC{}: only {} principal component(s) are "
522+
"available. Reduce --PCs or increase --ntop.\n".format(max(PCs), eigenvalues.size))
523+
# In the untransposed layout each point is a sample indexed along
524+
# the component axis, so there must be at least as many components
525+
# as samples (i.e. enough usable rows / a large enough --ntop).
526+
if not self.transpose and Wt.shape[1] < n:
527+
sys.exit("Not enough principal components ({}) to plot {} "
528+
"samples; increase --ntop to at least the sample "
529+
"count.\n".format(Wt.shape[1], n))
499530
markers = itertools.cycle(matplotlib.markers.MarkerStyle.filled_markers)
500531
if cols is not None:
501532
colors = itertools.cycle(cols)

pydeeptools/deeptools/test/test_plotPCA.py

Lines changed: 205 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import filecmp
33
import numpy as np
4+
import pytest
45
from matplotlib.testing.compare import compare_images
56
from tempfile import NamedTemporaryFile
67
import deeptools.plotPCA
@@ -12,6 +13,63 @@
1213
print(ROOT)
1314
tolerance = 50
1415

16+
17+
def _run_pca(extra=None, plot=True):
18+
"""Run plotPCA over the shared test matrix and return the parsed
19+
--outFileNameData table (header stripped). ``extra`` is a list of extra
20+
CLI tokens. When ``plot`` is True a plot file is also requested so the
21+
full plotting path runs; set it False to exercise only the numeric output
22+
(e.g. small --ntop values whose plotting path is separately broken)."""
23+
tsvfile = NamedTemporaryFile(suffix='.tsv', prefix='deeptools_testfile_', delete=False)
24+
args = "-in {0}test_samples.npz --outFileNameData {1}".format(
25+
TEST_DATA, tsvfile.name).split()
26+
plotfile = None
27+
if plot:
28+
plotfile = NamedTemporaryFile(suffix='.png', prefix='deeptools_testfile_', delete=False)
29+
args += ["-o", plotfile.name]
30+
if extra:
31+
args += extra
32+
deeptools.plotPCA.main(args)
33+
data = np.loadtxt(tsvfile.name, skiprows=1)
34+
os.remove(tsvfile.name)
35+
if plotfile is not None:
36+
os.remove(plotfile.name)
37+
return data
38+
39+
40+
def _sign_fix(coords, component_axis=1):
41+
"""PCA eigenvector signs are arbitrary: they flip across BLAS/platforms and
42+
between implementations (e.g. sklearn vs a scipy/SVD rewrite). The sign
43+
freedom is per principal component, so normalize each component's vector to
44+
have a positive largest-magnitude entry.
45+
46+
``component_axis`` says which axis indexes the principal components:
47+
in the untransposed --outFileNameData table components are the columns
48+
(axis=1); in the transposed table they are the rows (axis=0)."""
49+
coords = np.array(coords, dtype=float)
50+
if component_axis == 1:
51+
for j in range(coords.shape[1]):
52+
i = np.argmax(np.abs(coords[:, j]))
53+
if coords[i, j] < 0:
54+
coords[:, j] = -coords[:, j]
55+
else:
56+
for i in range(coords.shape[0]):
57+
j = np.argmax(np.abs(coords[i, :]))
58+
if coords[i, j] < 0:
59+
coords[i, :] = -coords[i, :]
60+
return coords
61+
62+
63+
# Golden eigenvalues captured from the sklearn-backed implementation over
64+
# test_samples.npz with the default --ntop 500. Eigenvalues are the portable
65+
# invariant (stable across BLAS backends and across the scipy/SVD rewrite);
66+
# untransposed per-feature coordinates are not (see test_plotPCA_default_eigenvalues).
67+
_GOLDEN_DEFAULT_EIGENVALUES = np.array([
68+
5.807692278756, 0.074230288836, 0.048971777735,
69+
0.036809415525, 0.026706723301, 0.017613563943,
70+
])
71+
72+
1573
def test_plotPCA_default():
1674
plotfile = NamedTemporaryFile(suffix='.png', prefix='deeptools_testfile_', delete=False)
1775
tsvfile = NamedTemporaryFile(suffix='.tsv', prefix='deeptools_testfile_', delete=False)
@@ -51,4 +109,150 @@ def test_plotPCA_outFileNameData():
51109
np.testing.assert_allclose(eigenvalues, expected_eigenvalues, rtol=1e-5)
52110

53111
os.remove(plotfile.name)
54-
os.remove(tsvfile.name)
112+
os.remove(tsvfile.name)
113+
114+
115+
def test_plotPCA_default_eigenvalues():
116+
"""Regression on the untransposed eigenvalues, the portable numeric
117+
invariant of this path.
118+
119+
We deliberately do NOT assert the projected coordinates here. After PC1
120+
the eigenvalues are tiny and near-degenerate (~0.07, 0.05, 0.04, ...), so
121+
the corresponding eigenvectors are free to rotate within that subspace,
122+
and the top-``ntop`` row selection (np.argpartition) breaks variance ties
123+
differently across BLAS backends (Linux OpenBLAS vs macOS Accelerate).
124+
The resulting per-feature coordinates are therefore not reproducible
125+
across platforms/implementations. Coordinate-level regression is covered
126+
by test_plotPCA_transpose, whose components are well separated and stable.
127+
The default plot itself is still pinned by test_plotPCA_default (image
128+
comparison)."""
129+
data = _run_pca()
130+
np.testing.assert_array_equal(data[:, 0], np.arange(1, 7))
131+
np.testing.assert_allclose(data[:, -1], _GOLDEN_DEFAULT_EIGENVALUES, rtol=1e-5)
132+
133+
134+
def test_plotPCA_variance_matches_eigenvalues():
135+
"""The per-PC variance fraction shown on the axis labels / scree plot is the
136+
eigenvalue proportion. Pin that relationship so the rewrite keeps the two in
137+
sync (eigenvalues are monotonically non-increasing and normalize to 1)."""
138+
eig = _run_pca()[:, -1]
139+
assert np.all(np.diff(eig) <= 1e-9), "eigenvalues must be non-increasing"
140+
pvar = eig / eig.sum()
141+
np.testing.assert_allclose(pvar.sum(), 1.0, rtol=1e-9)
142+
# PC1 dominates on this synthetic wt/kd matrix.
143+
assert pvar[0] > 0.9
144+
145+
146+
def test_plotPCA_ntop_zero_uses_all_rows():
147+
"""--ntop 0 disables the top-variable-rows filter and therefore changes the
148+
result relative to the default --ntop 500 (the test matrix has >500 rows)."""
149+
default = _run_pca()
150+
allrows = _run_pca(["--ntop", "0"])
151+
assert allrows.shape == (6, 8)
152+
# Different row selection -> different eigenvalues.
153+
assert not np.allclose(default[:, -1], allrows[:, -1])
154+
# Eigenvalues still normalize and stay ordered.
155+
eig = allrows[:, -1]
156+
assert np.all(np.diff(eig) <= 1e-9)
157+
158+
159+
def test_plotPCA_ntop_smaller_than_samples():
160+
"""When --ntop is below the sample count the table is truncated to the
161+
number of retained components (rows)."""
162+
# plot=False: the numeric table is well-defined even with 2 features.
163+
data = _run_pca(["--ntop", "2"], plot=False)
164+
assert data.shape == (2, 4)
165+
np.testing.assert_array_equal(data[:, 0], np.arange(1, 3))
166+
# First component carries all the variance for the 2-feature case.
167+
np.testing.assert_allclose(data[0, -1], 12.0, rtol=1e-6)
168+
assert abs(data[1, -1]) < 1e-6
169+
170+
171+
def test_plotPCA_ntop_below_samples_plot_errors_cleanly():
172+
"""Plotting with fewer retained components than samples cannot lay out the
173+
scatter; the tool must exit with a clear message rather than crash with an
174+
IndexError (previously a bug at correlation.py's scatter loop)."""
175+
plotfile = NamedTemporaryFile(suffix='.png', prefix='deeptools_testfile_', delete=False)
176+
args = "-in {0}test_samples.npz -o {1} --ntop 2".format(TEST_DATA, plotfile.name).split()
177+
try:
178+
with pytest.raises(SystemExit) as exc:
179+
deeptools.plotPCA.main(args)
180+
assert "principal component" in str(exc.value)
181+
finally:
182+
if os.path.exists(plotfile.name):
183+
os.remove(plotfile.name)
184+
185+
186+
def test_plotPCA_PCs_selection_does_not_change_table():
187+
"""--PCs only selects which components are drawn; the numeric table always
188+
contains every component, so it is independent of --PCs."""
189+
default = _run_pca()
190+
pcs13 = _run_pca(["--PCs", "1", "3"])
191+
np.testing.assert_allclose(default, pcs13, rtol=1e-9, atol=1e-12)
192+
193+
194+
@pytest.mark.parametrize("extra, msg", [
195+
(["--PCs", "2", "2"], "different principal components"),
196+
(["--PCs", "0", "1"], "at least 1"),
197+
(["--ntop", "-1"], "must be >= 0"),
198+
])
199+
def test_plotPCA_invalid_arguments_exit(extra, msg):
200+
plotfile = NamedTemporaryFile(suffix='.png', prefix='deeptools_testfile_', delete=False)
201+
args = "-in {0}test_samples.npz -o {1}".format(TEST_DATA, plotfile.name).split() + extra
202+
try:
203+
with pytest.raises(SystemExit) as exc:
204+
deeptools.plotPCA.main(args)
205+
assert msg in str(exc.value)
206+
finally:
207+
if os.path.exists(plotfile.name):
208+
os.remove(plotfile.name)
209+
210+
211+
def test_plotPCA_requires_an_output():
212+
with pytest.raises(SystemExit) as exc:
213+
deeptools.plotPCA.main("-in {0}test_samples.npz".format(TEST_DATA).split())
214+
assert "must be specified" in str(exc.value)
215+
216+
217+
# Golden values for the transposed PCA (samples as observations, so each row
218+
# of the table is a component's projection across the six samples). Captured
219+
# after fixing the projection bug; stored raw (components are rows -> axis=0).
220+
_GOLDEN_TRANSPOSE_COORDS = np.array([
221+
[8.096369192617, 27.65422672360, -1.598082844166, -15.48892072797, -18.49767188707, -0.1659204570140],
222+
[3.552671394141, -4.837722476763, 20.02992087542, 0.4882670876827, -7.713434681130, -11.51970219935],
223+
[10.09925342625, -9.161879672887, 1.351881375625, -11.06368229223, -0.2099739792611, 8.984401142503],
224+
[-11.75933735644, 2.772538322120, 7.637287613484, -8.588903203498, 5.490108421837, 4.448306202499],
225+
[4.468893041249, 2.035996403779, -1.674415783401, -5.444924755301, 9.786060422166, -9.171609328491],
226+
[3.400996688227e-15, 3.400996688227e-15, 3.400996688227e-15, 3.400996688227e-15, 3.400996688227e-15, 3.400996688227e-15],
227+
])
228+
_GOLDEN_TRANSPOSE_EIGENVALUES = np.array([
229+
282.9918757435, 125.9323562327, 78.18623219731,
230+
65.59902453902, 47.29051128753, 1.388013416800e-29,
231+
])
232+
233+
234+
def test_plotPCA_transpose():
235+
"""--transpose runs (previously crashed) and projects each sample onto the
236+
PCs. Coordinates are compared sign-invariantly; the last component is a
237+
numerical-zero residual so we skip its unstable sign."""
238+
data = _run_pca(["--transpose"])
239+
assert data.shape == (6, 8)
240+
np.testing.assert_array_equal(data[:, 0], np.arange(1, 7))
241+
# Transposed table: components are rows -> sign-fix per row (axis=0).
242+
coords = _sign_fix(data[:, 1:7], component_axis=0)
243+
golden = _sign_fix(_GOLDEN_TRANSPOSE_COORDS, component_axis=0)
244+
# Compare the informative components; the final ~1e-15 residual row is noise.
245+
np.testing.assert_allclose(coords[:-1], golden[:-1], rtol=1e-4, atol=1e-6)
246+
np.testing.assert_allclose(data[:, -1], _GOLDEN_TRANSPOSE_EIGENVALUES, rtol=1e-4, atol=1e-6)
247+
# Transposed eigenvalues differ from the untransposed layout.
248+
assert not np.allclose(data[:, -1], _GOLDEN_DEFAULT_EIGENVALUES)
249+
250+
251+
def test_plotPCA_log2_and_rowCenter_affect_output():
252+
"""--log2 and --rowCenter now actually transform the data before the PCA,
253+
so each changes the result relative to the default."""
254+
default = _run_pca()
255+
log2 = _run_pca(["--log2"])
256+
rowcenter = _run_pca(["--rowCenter"])
257+
assert not np.allclose(default[:, -1], log2[:, -1]), "--log2 was a no-op"
258+
assert not np.allclose(default[:, -1], rowcenter[:, -1]), "--rowCenter was a no-op"

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,6 @@ dependencies = [
3434
"pysam >= 0.23",
3535
"pyBigWig >= 0.3",
3636
"py2bit >= 0.3",
37-
"pandas >= 2.2",
38-
"scikit-learn >= 1.6",
3937
"deeptoolsintervals >= 0.1",
4038
"maturin"
4139
]

0 commit comments

Comments
 (0)