Skip to content

Commit 881c168

Browse files
Merge pull request #143 from pavlin-policar/py39
Add Python 3.9 support
2 parents 10649e7 + 497ba1b commit 881c168

File tree

6 files changed

+117
-89
lines changed

6 files changed

+117
-89
lines changed

azure-pipelines-release.yml

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ jobs:
2828
osx - python38:
2929
image.name: 'macos-10.14'
3030
python.version: '3.8'
31+
osx - python39:
32+
image.name: 'macos-10.14'
33+
python.version: '3.9'
3134

3235
windows - python36:
3336
image.name: 'vs2017-win2016'
@@ -38,6 +41,9 @@ jobs:
3841
windows - python38:
3942
image.name: 'vs2017-win2016'
4043
python.version: '3.8'
44+
windows - python39:
45+
image.name: 'vs2017-win2016'
46+
python.version: '3.9'
4147

4248
steps:
4349
- task: UsePythonVersion@0
@@ -71,10 +77,12 @@ jobs:
7177
- bash: python -m pip install -vv --force-reinstall --find-links dist openTSNE
7278
displayName: 'Install wheel'
7379

74-
- script: |
75-
python -m pip install pynndescent
76-
python -m pip install hnswlib
77-
displayName: 'Install optional dependencies'
80+
- script: pip install pynndescent
81+
displayName: 'Install optional dependencies - pynndescent'
82+
condition: ne(variables['python.version'], '3.9')
83+
84+
- script: pip install hnswlib
85+
displayName: 'Install optional dependencies - hnswlib'
7886

7987
- script: pytest -v
8088
timeoutInMinutes: 15
@@ -104,10 +112,16 @@ jobs:
104112
matrix:
105113
python36:
106114
python: '/opt/python/cp36-cp36m/bin'
115+
python.version: '3.6'
107116
python37:
108117
python: '/opt/python/cp37-cp37m/bin'
118+
python.version: '3.7'
109119
python38:
110120
python: '/opt/python/cp38-cp38/bin'
121+
python.version: '3.8'
122+
python39:
123+
python: '/opt/python/cp39-cp39/bin'
124+
python.version: '3.9'
111125

112126
container:
113127
image: quay.io/pypa/manylinux1_x86_64:latest
@@ -132,10 +146,12 @@ jobs:
132146
- bash: mv openTSNE src
133147
displayName: 'Remove source files from path'
134148

135-
- script: |
136-
$(python)/pip install pynndescent
137-
$(python)/pip install hnswlib
138-
displayName: 'Install optional dependencies'
149+
- script: $(python)/pip install pynndescent
150+
displayName: 'Install optional dependencies - pynndescent'
151+
condition: ne(variables['python.version'], '3.9')
152+
153+
- script: $(python)/pip install hnswlib
154+
displayName: 'Install optional dependencies - hnswlib'
139155

140156
- script: $(python)/python -m pytest -v
141157
timeoutInMinutes: 15

azure-pipelines.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ jobs:
2222
linux-python38:
2323
image.name: 'ubuntu-16.04'
2424
python.version: '3.8'
25+
linux-python39:
26+
image.name: 'ubuntu-16.04'
27+
python.version: '3.9'
2528
osx-python36:
2629
image.name: 'macos-10.14'
2730
python.version: '3.6'
@@ -31,6 +34,9 @@ jobs:
3134
osx-python38:
3235
image.name: 'macos-10.14'
3336
python.version: '3.8'
37+
osx-python39:
38+
image.name: 'macos-10.14'
39+
python.version: '3.9'
3440
windows-python36:
3541
image.name: 'vs2017-win2016'
3642
python.version: '3.6'
@@ -40,6 +46,9 @@ jobs:
4046
windows-python38:
4147
image.name: 'vs2017-win2016'
4248
python.version: '3.8'
49+
windows-python39:
50+
image.name: 'vs2017-win2016'
51+
python.version: '3.9'
4352

4453
steps:
4554
- task: UsePythonVersion@0
@@ -61,10 +70,12 @@ jobs:
6170
- script: pip install -vv .
6271
displayName: 'Install package'
6372

64-
- script: |
65-
pip install pynndescent
66-
pip install hnswlib
67-
displayName: 'Install optional dependencies'
73+
- script: pip install pynndescent
74+
displayName: 'Install optional dependencies - pynndescent'
75+
condition: ne(variables['python.version'], '3.9')
76+
77+
- script: pip install hnswlib
78+
displayName: 'Install optional dependencies - hnswlib'
6879

6980
# Since Python automatically adds `cwd` to `sys.path`, it's important we remove the local folder
7081
# containing our code from the working directory. Otherwise, the tests will use the local copy

openTSNE/affinity.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from openTSNE import _tsne
1010
from openTSNE import nearest_neighbors
1111
from openTSNE import utils
12+
from openTSNE.utils import is_package_installed
1213

1314
log = logging.getLogger(__name__)
1415

@@ -273,7 +274,8 @@ def check_perplexity(self, perplexity):
273274
def build_knn_index(
274275
data, method, k, metric, metric_params=None, n_jobs=1, random_state=None, verbose=False
275276
):
276-
if not sp.issparse(data) and metric in [
277+
preferred_approx_method = nearest_neighbors.Annoy
278+
if is_package_installed("pynndescent") and sp.issparse(data) and metric not in [
277279
"cosine",
278280
"euclidean",
279281
"manhattan",
@@ -283,8 +285,6 @@ def build_knn_index(
283285
"l2",
284286
"taxicab",
285287
]:
286-
preferred_approx_method = nearest_neighbors.Annoy
287-
else:
288288
preferred_approx_method = nearest_neighbors.NNDescent
289289

290290
if data.shape[0] < 1000:

openTSNE/utils.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,14 @@ def func(*args, **kwargs):
3232
return f(*args, **kwargs)
3333
return func
3434
return wrapper
35+
36+
37+
def is_package_installed(libname):
38+
"""Check whether a python package is installed."""
39+
import importlib
40+
41+
try:
42+
importlib.import_module(libname)
43+
return True
44+
except ImportError:
45+
return False

tests/test_nearest_neighbors.py

Lines changed: 59 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,12 @@
44
import numpy as np
55
import scipy.sparse as sp
66
from scipy.spatial.distance import pdist, cdist, squareform
7-
import pynndescent
8-
import hnswlib
97
from sklearn import datasets
108

11-
from numba import njit
12-
from numba.core.registry import CPUDispatcher
139
from sklearn.utils import check_random_state
1410

1511
from openTSNE import nearest_neighbors
12+
from openTSNE.utils import is_package_installed
1613
from .test_tsne import check_mock_called_with_kwargs
1714

1815

@@ -76,10 +73,6 @@ class TestAnnoy(KNNIndexTestMixin, unittest.TestCase):
7673
knn_index = nearest_neighbors.Annoy
7774

7875

79-
class TestHNSW(KNNIndexTestMixin, unittest.TestCase):
80-
knn_index = nearest_neighbors.HNSW
81-
82-
8376
class TestBallTree(KNNIndexTestMixin, unittest.TestCase):
8477
knn_index = nearest_neighbors.BallTree
8578

@@ -145,43 +138,37 @@ def manhattan(x, y):
145138
distances, true_distances_, err_msg="Distances do not match"
146139
)
147140

148-
def test_numba_compiled_callable_metric_same_result(self):
149-
k = 15
150141

151-
knn_index = self.knn_index("manhattan", random_state=1)
152-
knn_index.build(self.x1, k=k)
153-
true_indices_, true_distances_ = knn_index.query(self.x2, k=k)
154-
155-
@njit(fastmath=True)
156-
def manhattan(x, y):
157-
result = 0.0
158-
for i in range(x.shape[0]):
159-
result += np.abs(x[i] - y[i])
160-
161-
return result
142+
@unittest.skipIf(not is_package_installed("hnswlib"), "`hnswlib`is not installed")
143+
class TestHNSW(KNNIndexTestMixin, unittest.TestCase):
144+
knn_index = nearest_neighbors.HNSW
162145

163-
knn_index = self.knn_index(manhattan, random_state=1)
164-
knn_index.build(self.x1, k=k)
165-
indices, distances = knn_index.query(self.x2, k=k)
166-
np.testing.assert_array_equal(
167-
indices, true_indices_, err_msg="Nearest neighbors do not match"
168-
)
169-
np.testing.assert_allclose(
170-
distances, true_distances_, err_msg="Distances do not match"
171-
)
146+
@classmethod
147+
def setUpClass(cls):
148+
global hnswlib
149+
import hnswlib
172150

173151

152+
@unittest.skipIf(not is_package_installed("pynndescent"), "`pynndescent`is not installed")
174153
class TestNNDescent(KNNIndexTestMixin, unittest.TestCase):
175154
knn_index = nearest_neighbors.NNDescent
176155

177-
@patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent)
178-
def test_random_state_being_passed_through(self, nndescent):
156+
@classmethod
157+
def setUpClass(cls):
158+
global pynndescent, njit, CPUDispatcher
159+
160+
import pynndescent
161+
from numba import njit
162+
from numba.core.registry import CPUDispatcher
163+
164+
def test_random_state_being_passed_through(self):
179165
random_state = 1
180-
knn_index = nearest_neighbors.NNDescent("euclidean", random_state=random_state)
181-
knn_index.build(self.x1, k=30)
166+
with patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent) as nndescent:
167+
knn_index = nearest_neighbors.NNDescent("euclidean", random_state=random_state)
168+
knn_index.build(self.x1, k=30)
182169

183-
nndescent.assert_called_once()
184-
check_mock_called_with_kwargs(nndescent, {"random_state": random_state})
170+
nndescent.assert_called_once()
171+
check_mock_called_with_kwargs(nndescent, {"random_state": random_state})
185172

186173
def test_uncompiled_callable_is_compiled(self):
187174
knn_index = nearest_neighbors.NNDescent("manhattan")
@@ -245,47 +232,47 @@ def manhattan(x, y):
245232
distances, true_distances_, err_msg="Distances do not match"
246233
)
247234

248-
@patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent)
249-
def test_building_with_lt15_builds_proper_graph(self, nndescent):
250-
knn_index = nearest_neighbors.NNDescent("euclidean")
251-
indices, distances = knn_index.build(self.x1, k=10)
235+
def test_building_with_lt15_builds_proper_graph(self):
236+
with patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent) as nndescent:
237+
knn_index = nearest_neighbors.NNDescent("euclidean")
238+
indices, distances = knn_index.build(self.x1, k=10)
252239

253-
self.assertEqual(indices.shape, (self.x1.shape[0], 10))
254-
self.assertEqual(distances.shape, (self.x1.shape[0], 10))
255-
self.assertFalse(np.all(indices[:, 0] == np.arange(self.x1.shape[0])))
240+
self.assertEqual(indices.shape, (self.x1.shape[0], 10))
241+
self.assertEqual(distances.shape, (self.x1.shape[0], 10))
242+
self.assertFalse(np.all(indices[:, 0] == np.arange(self.x1.shape[0])))
256243

257244
# Should be called with 11 because nearest neighbor in pynndescent is itself
258245
check_mock_called_with_kwargs(nndescent, dict(n_neighbors=11))
259246

260-
@patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent)
261-
def test_building_with_gt15_calls_query(self, nndescent):
262-
nndescent.query = MagicMock(wraps=nndescent.query)
263-
knn_index = nearest_neighbors.NNDescent("euclidean")
264-
indices, distances = knn_index.build(self.x1, k=30)
265-
266-
self.assertEqual(indices.shape, (self.x1.shape[0], 30))
267-
self.assertEqual(distances.shape, (self.x1.shape[0], 30))
268-
self.assertFalse(np.all(indices[:, 0] == np.arange(self.x1.shape[0])))
269-
270-
# The index should be built with 15 neighbors
271-
check_mock_called_with_kwargs(nndescent, dict(n_neighbors=15))
272-
# And subsequently queried with the correct number of neighbors. Check
273-
# for 31 neighbors because query will return the original point as well,
274-
# which we don't consider.
275-
check_mock_called_with_kwargs(nndescent.query, dict(k=31))
276-
277-
@patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent)
278-
def test_runs_with_correct_njobs_if_dense_input(self, nndescent):
279-
knn_index = nearest_neighbors.NNDescent("euclidean", n_jobs=2)
280-
knn_index.build(self.x1, k=5)
281-
check_mock_called_with_kwargs(nndescent, dict(n_jobs=2))
282-
283-
@patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent)
284-
def test_runs_with_correct_njobs_if_sparse_input(self, nndescent):
285-
x_sparse = sp.csr_matrix(self.x1)
286-
knn_index = nearest_neighbors.NNDescent("euclidean", n_jobs=2)
287-
knn_index.build(x_sparse, k=5)
288-
check_mock_called_with_kwargs(nndescent, dict(n_jobs=2))
247+
def test_building_with_gt15_calls_query(self):
248+
with patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent) as nndescent:
249+
nndescent.query = MagicMock(wraps=nndescent.query)
250+
knn_index = nearest_neighbors.NNDescent("euclidean")
251+
indices, distances = knn_index.build(self.x1, k=30)
252+
253+
self.assertEqual(indices.shape, (self.x1.shape[0], 30))
254+
self.assertEqual(distances.shape, (self.x1.shape[0], 30))
255+
self.assertFalse(np.all(indices[:, 0] == np.arange(self.x1.shape[0])))
256+
257+
# The index should be built with 15 neighbors
258+
check_mock_called_with_kwargs(nndescent, dict(n_neighbors=15))
259+
# And subsequently queried with the correct number of neighbors. Check
260+
# for 31 neighbors because query will return the original point as well,
261+
# which we don't consider.
262+
check_mock_called_with_kwargs(nndescent.query, dict(k=31))
263+
264+
def test_runs_with_correct_njobs_if_dense_input(self):
265+
with patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent) as nndescent:
266+
knn_index = nearest_neighbors.NNDescent("euclidean", n_jobs=2)
267+
knn_index.build(self.x1, k=5)
268+
check_mock_called_with_kwargs(nndescent, dict(n_jobs=2))
269+
270+
def test_runs_with_correct_njobs_if_sparse_input(self):
271+
with patch("pynndescent.NNDescent", wraps=pynndescent.NNDescent) as nndescent:
272+
x_sparse = sp.csr_matrix(self.x1)
273+
knn_index = nearest_neighbors.NNDescent("euclidean", n_jobs=2)
274+
knn_index.build(x_sparse, k=5)
275+
check_mock_called_with_kwargs(nndescent, dict(n_jobs=2))
289276

290277
def test_random_cluster_when_invalid_indices(self):
291278
class MockIndex:

tests/test_tsne.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from openTSNE.affinity import PerplexityBasedNN
1616
from openTSNE.nearest_neighbors import NNDescent
1717
from openTSNE.tsne import kl_divergence_bh, kl_divergence_fft
18+
from openTSNE.utils import is_package_installed
1819

1920
np.random.seed(42)
2021
affinity.log.setLevel(logging.ERROR)
@@ -216,6 +217,7 @@ def test_partial_embedding_optimize(self, param_name, param_value, gradient_desc
216217
check_call_contains_kwargs(gradient_descent.mock_calls[0], params)
217218

218219
@check_params({"metric": set(NNDescent.VALID_METRICS) - {"mahalanobis"}})
220+
@unittest.skipIf(not is_package_installed("pynndescent"), "`pynndescent`is not installed")
219221
@patch("pynndescent.NNDescent")
220222
def test_nndescent_distances(self, param_name, metric, nndescent: MagicMock):
221223
"""Distance metrics should be properly passed down to NN descent"""
@@ -234,6 +236,7 @@ def test_nndescent_distances(self, param_name, metric, nndescent: MagicMock):
234236
self.assertEqual(nndescent.call_count, 1)
235237
check_call_contains_kwargs(nndescent.mock_calls[0], {"metric": metric})
236238

239+
@unittest.skipIf(not is_package_installed("pynndescent"), "`pynndescent`is not installed")
237240
@patch("pynndescent.NNDescent")
238241
def test_nndescent_mahalanobis_distance(self, nndescent: MagicMock):
239242
"""Distance metrics and additional params should be correctly passed down to NN descent"""
@@ -576,12 +579,12 @@ def test_random_state_parameter_is_propagated_random_init_exact(self, init, neig
576579
check_mock_called_with_kwargs(neighbors, {"random_state": random_state})
577580

578581
@patch("openTSNE.initialization.pca", wraps=openTSNE.initialization.pca)
579-
@patch("openTSNE.nearest_neighbors.NNDescent", wraps=openTSNE.nearest_neighbors.NNDescent)
582+
@patch("openTSNE.nearest_neighbors.Annoy", wraps=openTSNE.nearest_neighbors.Annoy)
580583
def test_random_state_parameter_is_propagated_pca_init_approx(self, init, neighbors):
581584
random_state = 1
582585

583586
tsne = openTSNE.TSNE(
584-
neighbors="pynndescent",
587+
neighbors="approx",
585588
initialization="pca",
586589
random_state=random_state,
587590
)

0 commit comments

Comments
 (0)