Skip to content

Commit 89c91f4

Browse files
committed
Merge branch 'master' into fix-spahm-a
2 parents 21d29de + 559f964 commit 89c91f4

File tree

3 files changed

+104
-19
lines changed

3 files changed

+104
-19
lines changed

qstack/regression/local_kernels.py

Lines changed: 67 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,83 @@
22
33
Provides:
44
local_kernels_dict: Dictionary mapping kernel names to their implementations.
5+
RAM_BATCHING_SIZE: Max. RAM (in bytes) that can be used for batched compuation
6+
of Manhattan distance matrix for L_custom_py kernel. Can be modified before call
7+
using `local_kernels.RAM_BATCHING_SIZE = ...`.
58
"""
69

710
import os
811
import ctypes
912
import sysconfig
1013
import warnings
14+
import itertools
1115
import numpy as np
1216
import sklearn.metrics.pairwise as _SKLEARN_PAIRWISE
1317
from qstack.regression import __path__ as REGMODULE_PATH
1418

1519

20+
RAM_BATCHING_SIZE = 1024**3 * 5 # 5GiB
21+
22+
23+
def compute_distance_matrix(R1, R2):
24+
"""Compute the Manhattan-distance matrix.
25+
26+
This computes (||r_1 - r_2||_1) between the samples of R1 and R2,
27+
using a batched python/numpy implementation,
28+
designed to be more memory-efficient than a single numpy call and faster than a simple python for loop.
29+
30+
This function is a batched-over-R1 implementation of the following code:
31+
`return np.sum( (R1[:,None, ...]-R2[None,:, ...])**2, axis=tuple(range(2, R1.ndim)))`
32+
33+
Args:
34+
R1 (numpy ndarray): First set of samples (can be multi-dimensional).
35+
R2 (numpy ndarray): Second set of samples.
36+
37+
Returns:
38+
numpy ndarray: squared-distance matrix of shape (len(R1), len(R2)).
39+
40+
Raises:
41+
RuntimeError: If X and Y have incompatible shapes.
42+
"""
43+
if R1.ndim != R2.ndim or R1.shape[1:] != R2.shape[1:]:
44+
raise RuntimeError(f'incompatible shapes for R1 ({R1.shape:r}) and R2 ({R2.shape:r})')
45+
46+
# determine batch size (batch should divide the larger dimention)
47+
if R1.shape[0] < R2.shape[0]:
48+
transpose_flag = True
49+
R2,R1 = R1,R2
50+
else:
51+
transpose_flag = False
52+
dtype=np.result_type(R1,R2)
53+
out = np.zeros((R1.shape[0], R2.shape[0]), dtype=dtype)
54+
55+
# possible weirdness: how is the layout of dtype done if dtype.alignment != dtype.itemsize?
56+
batch_size = int(np.floor(RAM_BATCHING_SIZE/ (dtype.itemsize * np.prod(R2.shape))))
57+
58+
if batch_size == 0:
59+
batch_size = 1
60+
61+
if min(R1.shape[0],R2.shape[0]) == 0 or batch_size >= R1.shape[0]:
62+
dists = R1[:,None]-R2[None,:]
63+
#np.pow(dists, 2, out=dists) # For Euclidean distance
64+
np.abs(dists, out=dists)
65+
np.sum(dists, out=out, axis=tuple(range(2,dists.ndim)))
66+
else:
67+
dists = np.zeros((batch_size, *R2.shape), dtype=dtype)
68+
batch_limits = np.minimum(np.arange(0, R1.shape[0]+batch_size, step=batch_size), R1.shape[0])
69+
for batch_start, batch_end in itertools.pairwise(batch_limits):
70+
dists_view = dists[:batch_end-batch_start]
71+
R1_view = R1[batch_start:batch_end, None, ...]
72+
np.subtract(R1_view, R2[None,:], out=dists_view)
73+
#np.pow(dists_view, 2, out=dists_view) # For Euclidean distance
74+
np.abs(dists_view, out=dists_view)
75+
np.sum(dists_view, out=out[batch_start:batch_end], axis=tuple(range(2,dists.ndim)))
76+
77+
if transpose_flag:
78+
out = out.T
79+
return out
80+
81+
1682
def custom_laplacian_kernel(X, Y, gamma):
1783
"""Compute Laplacian kernel between X and Y using Python implementation.
1884
@@ -31,16 +97,7 @@ def custom_laplacian_kernel(X, Y, gamma):
3197
"""
3298
if X.shape[1:] != Y.shape[1:]:
3399
raise RuntimeError(f"Incompatible shapes {X.shape} and {Y.shape}")
34-
35-
def cdist(X, Y):
36-
K = np.zeros((len(X),len(Y)))
37-
for i,x in enumerate(X):
38-
x = np.array([x] * len(Y))
39-
d = np.abs(x-Y)
40-
d = np.sum(d, axis=tuple(range(1, len(d.shape))))
41-
K[i,:] = d
42-
return K
43-
K = -gamma * cdist(X, Y)
100+
K = -gamma * compute_distance_matrix(X,Y)
44101
np.exp(K, out=K)
45102
return K
46103

setup.py

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,33 @@
1-
from setuptools import setup, find_packages, Extension
1+
from setuptools import setup, Extension
22
import subprocess
3-
import os, tempfile, shutil
3+
import os
4+
import tempfile
5+
import shutil
6+
7+
# ruff: noqa: S607 # look, if people are installing qstack while using a borked PATH, that's on them
8+
# ruff: noqa: D100 # this is a setup.py file, no docstring is needed
49

510
VERSION="0.0.1"
611

712
def get_git_version_hash():
813
"""Get tag/hash of the latest commit.
9-
Thanks to https://gist.github.com/nloadholtes/07a1716a89b53119021592a1d2b56db8"""
14+
15+
Thanks to https://gist.github.com/nloadholtes/07a1716a89b53119021592a1d2b56db8
16+
"""
1017
try:
1118
p = subprocess.Popen(["git", "describe", "--tags", "--dirty", "--always"], stdout=subprocess.PIPE)
12-
except EnvironmentError:
19+
except OSError:
1320
return VERSION + "+unknown"
1421
version = p.communicate()[0]
1522
print(version)
1623
return VERSION+'+'+version.strip().decode()
1724

1825

1926
def check_for_openmp():
20-
"""Check if there is OpenMP available
21-
Thanks to https://stackoverflow.com/questions/16549893/programatically-testing-for-openmp-support-from-a-python-setup-script"""
27+
"""Check if there is OpenMP available.
28+
29+
Thanks to https://stackoverflow.com/questions/16549893/programatically-testing-for-openmp-support-from-a-python-setup-script
30+
"""
2231
omp_test = 'void main() { }'
2332
tmpdir = tempfile.mkdtemp()
2433
curdir = os.getcwd()
@@ -27,7 +36,7 @@ def check_for_openmp():
2736
with open(filename, 'w') as file:
2837
file.write(omp_test)
2938
with open(os.devnull, 'w') as fnull:
30-
result = subprocess.call(['cc', '-fopenmp', '-lgomp', filename], stdout=fnull, stderr=fnull)
39+
result = subprocess.call(['cc', '-fopenmp', '-lgomp', filename], stdout=fnull, stderr=fnull) # noqa: S603 (filename is hard-coded earlier in this function)
3140
os.chdir(curdir)
3241
shutil.rmtree(tmpdir)
3342
return not result
@@ -41,6 +50,6 @@ def check_for_openmp():
4150
ext_modules=[Extension('qstack.regression.lib.manh',
4251
['qstack/regression/lib/manh.c'],
4352
extra_compile_args=['-fopenmp', '-std=gnu11'] if openmp_enabled else ['-std=gnu11'],
44-
extra_link_args=['-lgomp'] if openmp_enabled else [])
53+
extra_link_args=['-lgomp'] if openmp_enabled else []),
4554
],
4655
)

tests/test_kernels.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22

33
import numpy as np
4-
from qstack.regression import kernel
4+
from qstack.regression import kernel, local_kernels
55

66

77
def test_local_kernels():
@@ -48,5 +48,24 @@ def test_local_kernels():
4848
assert np.allclose(K, K_cos_good)
4949

5050

51+
def test_batched_local_kernels():
52+
X = np.array([[0.70043712, 0.84418664, 0.67651434, 0.72785806], [0.95145796, 0.0127032 , 0.4135877 , 0.04881279]])
53+
Y = np.array([[0.09992856, 0.50806631, 0.20024754, 0.74415417], [0.192892 , 0.70084475, 0.29322811, 0.77447945]])
54+
K_L_good = np.array([[0.48938983, 0.58251676], [0.32374891, 0.31778924]])
55+
56+
X_huge = np.tile(X, (1000,1000))
57+
Y_huge = np.tile(Y, (50,1000))
58+
K_L_good_huge = np.tile(K_L_good, (1000,50))
59+
60+
local_kernels.RAM_BATCHING_SIZE = 1024**2 * 50 # 50MiB
61+
62+
K = kernel.kernel(X_huge, Y_huge, akernel='L_custom_py', sigma=2.0*1000)
63+
assert np.allclose(K, K_L_good_huge)
64+
65+
K = kernel.kernel(X_huge.reshape((-1, 50, 80)), Y_huge.reshape((-1, 50, 80)), akernel='L_custom_py', sigma=2.0*1000)
66+
assert np.allclose(K, K_L_good_huge)
67+
68+
5169
if __name__ == '__main__':
5270
test_local_kernels()
71+
test_batched_local_kernels()

0 commit comments

Comments
 (0)