From fcc3bf0a5cc4f7b855a60604995181deb7e6ffea Mon Sep 17 00:00:00 2001 From: James Bannon Date: Mon, 31 Aug 2026 16:04:42 -0400 Subject: [PATCH 1/5] adding pre-computed affinity metric functionality --- src/snf2/__init__.py | 4 +-- src/snf2/affinity.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/snf2/__init__.py b/src/snf2/__init__.py index 2aa3c81..c826963 100644 --- a/src/snf2/__init__.py +++ b/src/snf2/__init__.py @@ -1,6 +1,6 @@ """SNF2: a modern Python implementation of Similarity Network Fusion.""" -from snf2.affinity import make_affinity +from snf2.affinity import make_affinity,affinity_matrix from snf2.fusion import fuse -__all__ = ["fuse", "make_affinity"] +__all__ = ["fuse", "make_affinity","affinity_matrix"] diff --git a/src/snf2/affinity.py b/src/snf2/affinity.py index 47557cf..cbb4b68 100644 --- a/src/snf2/affinity.py +++ b/src/snf2/affinity.py @@ -106,3 +106,71 @@ def make_affinity( raise ValueError("affinity computation produced non-finite values") return affinities + + + +def affinity_matrix( + diff: np.ndarray, + n_neighbors: int = 20, + scale: float = 0.5, +) -> np.ndarray: + """Construct a similarity/affinity network from a distance matrix. + + Parameters + ---------- + diff : np.ndarray + Square pairwise-difference/distance matrix. + n_neighbors : int, default=20 + Number of nearest neighbors used to estimate local scale. + scale : float, default=0.5 + Scaling factor for the Gaussian density. + + Returns + ------- + np.ndarray + Symmetric affinity matrix. + """ + diff = np.asarray(diff, dtype=float) + + if diff.ndim != 2 or diff.shape[0] != diff.shape[1]: + raise ValueError("diff must be a square matrix") + + n = diff.shape[0] + eps = np.finfo(float).eps + + # Symmetrize and remove self-distances. + diff = (diff + diff.T) / 2 + np.fill_diagonal(diff, 0.0) + + # For each column, sort distances and take the first k + 1 values. + # The +1 includes the diagonal zero. + sorted_columns = np.sort(diff, axis=0) + + if k + 1 > n: + raise ValueError(f"k must satisfy k + 1 <= n (got k={k}, n={n})") + + nearest = sorted_columns[:, : k + 1] + + # R's mean(x[is.finite(x)]), applied row-wise. + finite = np.isfinite(nearest) + counts = finite.sum(axis=1) + means = np.divide( + np.where(finite, nearest, 0.0).sum(axis=1), + counts, + where=counts > 0, + ) + means += eps + + # Equivalent to: + # outer(means, means, avg) / 3 * 2 + Diff / 3 + eps + sig = (2.0 / 3.0) * ((means[:, None] + means[None, :]) / 2) + sig += diff / 3.0 + sig += eps + + sig = np.maximum(sig, eps) + + # Gaussian density: dnorm(Diff, mean=0, sd=sigma * Sig) + densities = norm.pdf(diff, loc=0.0, scale=scale * sig) + + # Ensure the resulting affinity matrix is symmetric. + return (densities + densities.T) / 2 \ No newline at end of file From fa314c2a6f0a5d370af9ea5c9bc6f4b8ec7b6c0c Mon Sep 17 00:00:00 2001 From: Michael Tran Date: Thu, 3 Sep 2026 14:16:03 -0400 Subject: [PATCH 2/5] docs: cleanup and update docs for affinity_matrix --- README.md | 60 +++++++++++------------------------------------- docs/devnotes.md | 9 ++++++-- docs/index.md | 21 ++++++++++++++++- 3 files changed, 41 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 6c1493b..8cb9db5 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ ```python import numpy as np -from snf2 import fuse, make_affinity +from snf2 import affinity_matrix, fuse, make_affinity modality_a = np.array( [[0.0, 1.0], [0.2, 0.8], [1.0, 0.1], [0.9, 0.2]], @@ -66,58 +66,26 @@ Metric-specific data requirements follow SciPy. SNF2 raises an error if a metric produces non-finite or negative pairwise distances for the supplied data. -SNF2 currently provides the two core algorithm stages: constructing an -affinity matrix from one feature matrix and fusing affinity matrices across -modalities. - -## Usage +For precomputed distances, use `affinity_matrix` directly: ```python -import numpy as np - -from snf2 import fuse, make_affinity - -modality_a = np.array( - [[0.0, 1.0], [0.2, 0.8], [1.0, 0.1], [0.9, 0.2]], +distances = np.array( + [ + [0.0, 0.3, 1.2, 1.0], + [0.3, 0.0, 1.0, 0.8], + [1.2, 1.0, 0.0, 0.2], + [1.0, 0.8, 0.2, 0.0], + ], ) -modality_b = np.array( - [[1.0, 0.0], [0.8, 0.1], [0.1, 1.0], [0.2, 0.9]], -) - -affinities = [ - make_affinity(modality_a, n_neighbors=2), - make_affinity(modality_b, n_neighbors=2), -] -fused_network = fuse(affinities, n_neighbors=2) -``` - -Rows are samples and columns are features. SNF2 does not standardize or align -inputs: callers must preprocess each modality and ensure identical sample -ordering before constructing affinities. - -Affinity construction defaults to squared Euclidean distance and accepts every -named metric supported by -[`scipy.spatial.distance.pdist`](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.distance.pdist.html). -Use `metric_kwargs` for metric-specific arguments: - -```python -correlation_network = make_affinity( - modality_a, - metric="correlation", - n_neighbors=2, -) - -minkowski_network = make_affinity( - modality_a, - metric="minkowski", - metric_kwargs={"p": 3.5}, +precomputed_network = affinity_matrix( + distances, n_neighbors=2, ) ``` -Metric-specific data requirements follow SciPy. SNF2 raises an error if a -metric produces non-finite or negative pairwise distances for the supplied -data. +`affinity_matrix` accepts distances, not similarities. Convert a similarity +matrix with a transformation appropriate to that measure before calling it; +for a similarity bounded to `[0, 1]`, that may be `1 - similarity`. ## Development setup diff --git a/docs/devnotes.md b/docs/devnotes.md index 9b1fd14..7ee8ffd 100644 --- a/docs/devnotes.md +++ b/docs/devnotes.md @@ -5,11 +5,16 @@ that are important to future SNF2 contributors. ## Design decisions -No entries yet. +- `make_affinity` computes pairwise distances from sample-by-feature data and + delegates the shared SNF kernel to `affinity_matrix`. +- `affinity_matrix` accepts precomputed distances rather than similarities, so + callers remain responsible for choosing a scientifically appropriate + similarity-to-distance transformation. ## Compatibility notes -No entries yet. +- Missing-value handling belongs in preprocessing or in the distance + calculation. SNF2 does not define a generic `nan_policy` for SciPy metrics. ## Open questions diff --git a/docs/index.md b/docs/index.md index edb607d..24bdf09 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,7 +7,7 @@ The initial API separates affinity construction from network fusion: ```python import numpy as np -from snf2 import fuse, make_affinity +from snf2 import affinity_matrix, fuse, make_affinity rna = np.array([[0.0, 1.0], [0.2, 0.8], [1.0, 0.1], [0.9, 0.2]]) protein = np.array([[1.0, 0.0], [0.8, 0.1], [0.1, 1.0], [0.2, 0.9]]) @@ -33,5 +33,24 @@ at least two finite, nonnegative, symmetric affinity matrices with the same shape. Metric-specific data requirements follow SciPy; SNF2 rejects non-finite or negative pairwise distances before constructing affinities. +Use `affinity_matrix` when distances have already been computed: + +```python +distances = np.array( + [ + [0.0, 0.3, 1.2, 1.0], + [0.3, 0.0, 1.0, 0.8], + [1.2, 1.0, 0.0, 0.2], + [1.0, 0.8, 0.2, 0.0], + ], +) +precomputed_network = affinity_matrix(distances, n_neighbors=2) +``` + +The input to `affinity_matrix` is a distance matrix, not a similarity matrix. +Convert similarities with a transformation appropriate to the similarity +measure first; for a similarity bounded to `[0, 1]`, that may be +`1 - similarity`. + For setup and development commands, see the [project README](https://github.com/bhklab/snf2#readme). From d9d7aa251c7e0b0cf078d96e0d0981a9684fbde6 Mon Sep 17 00:00:00 2001 From: Michael Tran Date: Thu, 3 Sep 2026 14:16:42 -0400 Subject: [PATCH 3/5] fix: sort imports and update __all__ declaration --- src/snf2/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/snf2/__init__.py b/src/snf2/__init__.py index c826963..053323f 100644 --- a/src/snf2/__init__.py +++ b/src/snf2/__init__.py @@ -1,6 +1,6 @@ """SNF2: a modern Python implementation of Similarity Network Fusion.""" -from snf2.affinity import make_affinity,affinity_matrix +from snf2.affinity import affinity_matrix, make_affinity from snf2.fusion import fuse -__all__ = ["fuse", "make_affinity","affinity_matrix"] +__all__ = ["affinity_matrix", "fuse", "make_affinity"] From e557813d8b6a942f25c0ccf8a549ba6fcee00156 Mon Sep 17 00:00:00 2001 From: Michael Tran Date: Thu, 3 Sep 2026 14:22:17 -0400 Subject: [PATCH 4/5] refactor: streamline affinity matrix computation --- src/snf2/affinity.py | 102 +++++++++++++++++++------------------------ 1 file changed, 46 insertions(+), 56 deletions(-) diff --git a/src/snf2/affinity.py b/src/snf2/affinity.py index cbb4b68..ba91bb2 100644 --- a/src/snf2/affinity.py +++ b/src/snf2/affinity.py @@ -6,8 +6,10 @@ import numpy as np from numpy.typing import ArrayLike, NDArray from scipy.spatial.distance import pdist, squareform +from scipy.stats import norm from snf2._validation import ( + as_distance_matrix, as_feature_matrix, validate_n_neighbors, validate_positive_float, @@ -78,9 +80,6 @@ def make_affinity( distances = squareform( pdist_with_named_metric(matrix, metric=metric, **distance_kwargs) ) - distances = np.asarray((distances + distances.T) / 2, dtype=np.float64) - np.fill_diagonal(distances, 0) - if not np.all(np.isfinite(distances)): raise ValueError( f"metric {metric!r} produced non-finite pairwise distances; " @@ -89,76 +88,62 @@ def make_affinity( if np.any(distances < 0): raise ValueError(f"metric {metric!r} produced negative pairwise distances") - epsilon = np.finfo(np.float64).eps - sorted_distances = np.sort(distances, axis=1) - neighborhood_means = sorted_distances[:, 1 : neighbors + 1].mean(axis=1) + epsilon - local_widths = ( - neighborhood_means[:, None] + neighborhood_means[None, :] + distances - ) / 3 + epsilon - local_widths = np.maximum(local_widths, epsilon) - - kernel_widths = kernel_scale * local_widths - affinities = np.exp(-(distances**2) / (2 * kernel_widths**2)) - affinities /= np.sqrt(2 * np.pi) * kernel_widths - affinities = np.asarray((affinities + affinities.T) / 2, dtype=np.float64) - - if not np.all(np.isfinite(affinities)): - raise ValueError("affinity computation produced non-finite values") - - return affinities - + return affinity_matrix( + distances, + n_neighbors=neighbors, + scale=kernel_scale, + ) def affinity_matrix( - diff: np.ndarray, + distances: ArrayLike, + *, n_neighbors: int = 20, scale: float = 0.5, -) -> np.ndarray: - """Construct a similarity/affinity network from a distance matrix. +) -> NDArray[np.float64]: + """Construct an SNF affinity matrix from pairwise distances. Parameters ---------- - diff : np.ndarray - Square pairwise-difference/distance matrix. - n_neighbors : int, default=20 - Number of nearest neighbors used to estimate local scale. - scale : float, default=0.5 - Scaling factor for the Gaussian density. + distances + Square, finite, nonnegative, symmetric pairwise-distance matrix with a + zero diagonal. Similarities must first be converted to distances using + a transformation appropriate to the similarity measure. + n_neighbors + Number of nearest neighbors used to estimate each local scale. + scale + Positive multiplier applied to the locally estimated kernel width. Returns ------- - np.ndarray - Symmetric affinity matrix. - """ - diff = np.asarray(diff, dtype=float) - - if diff.ndim != 2 or diff.shape[0] != diff.shape[1]: - raise ValueError("diff must be a square matrix") + numpy.ndarray + A symmetric ``float64`` sample-by-sample affinity matrix. + Raises + ------ + TypeError + If the distances or parameters have incompatible types. + ValueError + If the distances or parameters have invalid values. + """ + diff = as_distance_matrix(distances) n = diff.shape[0] - eps = np.finfo(float).eps + k = validate_n_neighbors(n_neighbors, n) + kernel_scale = validate_positive_float(scale, name="scale") + eps = np.finfo(np.float64).eps # Symmetrize and remove self-distances. - diff = (diff + diff.T) / 2 + diff = np.asarray((diff + diff.T) / 2, dtype=np.float64) np.fill_diagonal(diff, 0.0) - # For each column, sort distances and take the first k + 1 values. - # The +1 includes the diagonal zero. - sorted_columns = np.sort(diff, axis=0) - - if k + 1 > n: - raise ValueError(f"k must satisfy k + 1 <= n (got k={k}, n={n})") - - nearest = sorted_columns[:, : k + 1] + # For each row, sort distances and take the first k non-self values. + sorted_rows = np.sort(diff, axis=1) + nearest = sorted_rows[:, 1 : k + 1] # R's mean(x[is.finite(x)]), applied row-wise. finite = np.isfinite(nearest) counts = finite.sum(axis=1) - means = np.divide( - np.where(finite, nearest, 0.0).sum(axis=1), - counts, - where=counts > 0, - ) + means = np.where(finite, nearest, 0.0).sum(axis=1) / counts means += eps # Equivalent to: @@ -166,11 +151,16 @@ def affinity_matrix( sig = (2.0 / 3.0) * ((means[:, None] + means[None, :]) / 2) sig += diff / 3.0 sig += eps - sig = np.maximum(sig, eps) - # Gaussian density: dnorm(Diff, mean=0, sd=sigma * Sig) - densities = norm.pdf(diff, loc=0.0, scale=scale * sig) + # Gaussian density: dnorm(Diff, mean=0, sd=scale * Sig) + densities = np.asarray( + norm.pdf(diff, loc=0.0, scale=kernel_scale * sig), + dtype=np.float64, + ) # Ensure the resulting affinity matrix is symmetric. - return (densities + densities.T) / 2 \ No newline at end of file + affinities = np.asarray((densities + densities.T) / 2, dtype=np.float64) + if not np.all(np.isfinite(affinities)): + raise ValueError("affinity computation produced non-finite values") + return affinities From 00d9afda113364935605b2a45c30920f81275777 Mon Sep 17 00:00:00 2001 From: Michael Tran Date: Thu, 3 Sep 2026 14:22:33 -0400 Subject: [PATCH 5/5] feat: add distance matrix validation and tests --- src/snf2/_validation.py | 41 ++++++++++++++++++ tests/test_affinity.py | 93 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/src/snf2/_validation.py b/src/snf2/_validation.py index 5f5fb2a..18fa81c 100644 --- a/src/snf2/_validation.py +++ b/src/snf2/_validation.py @@ -37,6 +37,47 @@ def as_feature_matrix(data: ArrayLike) -> NDArray[np.float64]: return matrix +def as_distance_matrix(distances: ArrayLike) -> NDArray[np.float64]: + """Return a validated pairwise-distance matrix as an owned float64 array.""" + try: + raw = np.asarray(distances) + except (TypeError, ValueError) as error: + raise TypeError("distances must be a rectangular real-valued array") from error + + if raw.ndim != 2 or raw.shape[0] != raw.shape[1]: + raise ValueError("distances must be a square matrix") + if raw.shape[0] < 2: + raise ValueError("distances must contain at least two samples") + if not np.issubdtype(raw.dtype, np.number) or np.issubdtype( + raw.dtype, np.complexfloating + ): + raise TypeError("distances must contain real numeric values") + + matrix = np.array(raw, dtype=np.float64, copy=True) + if not np.all(np.isfinite(matrix)): + raise ValueError("distances must contain only finite values") + if np.any(matrix < 0): + raise ValueError("distances must be nonnegative") + if not np.allclose( + matrix, + matrix.T, + rtol=SYMMETRY_RTOL, + atol=SYMMETRY_ATOL, + ): + raise ValueError("distances must be symmetric") + if not np.allclose( + np.diag(matrix), + 0, + rtol=0, + atol=SYMMETRY_ATOL, + ): + raise ValueError("distances must have a zero diagonal") + + matrix = np.asarray((matrix + matrix.T) / 2, dtype=np.float64) + np.fill_diagonal(matrix, 0) + return matrix + + def validate_n_neighbors(n_neighbors: int, n_samples: int) -> int: """Validate and return the requested neighborhood size.""" if isinstance(n_neighbors, bool) or not isinstance(n_neighbors, Integral): diff --git a/tests/test_affinity.py b/tests/test_affinity.py index 4849986..d65eb65 100644 --- a/tests/test_affinity.py +++ b/tests/test_affinity.py @@ -5,8 +5,9 @@ import numpy as np import pytest +from scipy.spatial.distance import pdist, squareform -from snf2 import make_affinity +from snf2 import affinity_matrix, make_affinity DATA_DIR = Path(__file__).parent / "reference" / "data" REFERENCE_RTOL = 1e-10 @@ -80,6 +81,96 @@ def test_make_affinity_matches_snftool(features_name: str, affinity_name: str) - ) +@pytest.mark.parametrize( + ("features_name", "affinity_name"), + [ + ("features_1.csv", "affinity_1.csv"), + ("features_2.csv", "affinity_2.csv"), + ], +) +def test_affinity_matrix_matches_snftool( + features_name: str, affinity_name: str +) -> None: + features = load_fixture(features_name) + distances = squareform(pdist(features, metric="euclidean")) + expected = load_fixture(affinity_name) + + actual = affinity_matrix( + distances, + n_neighbors=3, + scale=0.5, + ) + + np.testing.assert_allclose( + actual, + expected, + rtol=REFERENCE_RTOL, + atol=REFERENCE_ATOL, + ) + + +def test_affinity_matrix_returns_owned_symmetric_float64_array() -> None: + distances = squareform(pdist(CONTINUOUS_DATA, metric="euclidean")) + original = distances.copy() + + affinity = affinity_matrix(distances, n_neighbors=3) + + np.testing.assert_array_equal(distances, original) + assert affinity.dtype == np.float64 + assert affinity.shape == distances.shape + assert np.all(np.isfinite(affinity)) + assert np.all(affinity > 0) + np.testing.assert_allclose(affinity, affinity.T, rtol=0, atol=0) + assert not np.shares_memory(affinity, distances) + + +@pytest.mark.parametrize( + ("distances", "error", "message"), + [ + ([0.0, 1.0], ValueError, "square"), + (np.zeros((2, 3)), ValueError, "square"), + (np.zeros((1, 1)), ValueError, "at least two"), + (np.array([[0.0, np.nan], [np.nan, 0.0]]), ValueError, "finite"), + (np.array([[0.0, np.inf], [np.inf, 0.0]]), ValueError, "finite"), + (np.array([[0.0, -1.0], [-1.0, 0.0]]), ValueError, "nonnegative"), + (np.array([[0.0, 1.0], [2.0, 0.0]]), ValueError, "symmetric"), + (np.array([[1.0, 2.0], [2.0, 1.0]]), ValueError, "zero diagonal"), + (np.array([[0 + 0j, 1 + 0j], [1 + 0j, 0 + 0j]]), TypeError, "real"), + (np.array([["0", "1"], ["1", "0"]]), TypeError, "real"), + ], +) +def test_affinity_matrix_rejects_invalid_distances( + distances: object, + error: type[Exception], + message: str, +) -> None: + with pytest.raises(error, match=message): + affinity_matrix(cast(Any, distances), n_neighbors=1) + + +@pytest.mark.parametrize("n_neighbors", [0, 2, 1.5, True]) +def test_affinity_matrix_rejects_invalid_neighborhood(n_neighbors: object) -> None: + distances = np.array([[0.0, 1.0], [1.0, 0.0]]) + + with pytest.raises((TypeError, ValueError)): + affinity_matrix( + distances, + n_neighbors=cast(Any, n_neighbors), + ) + + +@pytest.mark.parametrize("scale", [0.0, -1.0, np.inf, np.nan, True]) +def test_affinity_matrix_rejects_invalid_scale(scale: object) -> None: + distances = np.array([[0.0, 1.0], [1.0, 0.0]]) + + with pytest.raises((TypeError, ValueError)): + affinity_matrix( + distances, + n_neighbors=1, + scale=cast(Any, scale), + ) + + def test_make_affinity_defaults_to_squared_euclidean() -> None: features = load_fixture("features_1.csv") expected = load_fixture("affinity_sqeuclidean_1.csv")