11import os
22import filecmp
33import numpy as np
4+ import pytest
45from matplotlib .testing .compare import compare_images
56from tempfile import NamedTemporaryFile
67import deeptools .plotPCA
1213print (ROOT )
1314tolerance = 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+
1573def 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"
0 commit comments