Examples¶
These examples show how to use PySRF for increasingly complex analyses,
from a basic factorization to constructing similarity matrices, selecting
the rank, building stable consensus embeddings, and running a complete
workflow. Every snippet runs against the real PySRF API and uses
random_state=42 for reproducibility.
Basic factorization¶
Start with a similarity matrix that has exact low-rank structure: build it from a known embedding w_true and ask SRF to reveal latent dimensions.
import numpy as np
from pysrf import SRF
# Ground-truth low-rank structure
rng = np.random.default_rng(42)
n, rank = 100, 10
w_true = rng.random((n, rank))
s = w_true @ w_true.T
# Reveal dimensions
model = SRF(rank=10, random_state=42)
w = model.fit_transform(s)
s_hat = model.reconstruct()
print(f"Score (negative MSE): {model.score(s):.6f}")
score returns negative mean squared error on observed entries, so higher is better and zero is a perfect fit. Because the input has exact rank 10, the score is close to zero. Real similarity matrices contain noise and their rank is unknown, so the later examples show how to estimate it.
Missing data¶
Similarity datasets are often incomplete: behavioral experiments sample
only a fraction of all item pairs, and some entries are simply missing.
SRF learns directly from the observed entries and treats the rest as
unobserved. Here we drop about 30% of the entries to NaN.
import numpy as np
from pysrf import SRF
rng = np.random.default_rng(42)
n, rank = 100, 10
w_true = rng.random((n, rank))
s = w_true @ w_true.T
# Mark ~30% of pairs as missing (symmetric mask)
mask = np.triu(rng.random((n, n)) < 0.3, k=1)
mask = mask | mask.T
s[mask] = np.nan
model = SRF(rank=10, missing_values=np.nan, random_state=42)
w = model.fit_transform(s)
s_completed = model.reconstruct() # fills in the missing pairs
SRF fits using only the observed pairs and then predicts the held-out ones in reconstruct(). This beats imputing the gaps first: the paper (§2.1) shows that k-nearest-neighbor or median imputation distorts the pairwise similarity structure and biases revealed dimensions, whereas leaving entries unobserved does not.
Cross-validated rank selection¶
The number of dimensions is usually unknown. cross_val_score selects the SRF model rank with a restricted hold-out scheme: it first calibrates the hold-out protocol from the similarity matrix, then evaluates held-out prediction error for the candidate ranks you provide.
import numpy as np
from pysrf import cross_val_score
rng = np.random.default_rng(42)
n, rank = 100, 10
w_true = rng.random((n, rank))
s = w_true @ w_true.T
cv = cross_val_score(s, range(1, 21), n_repeats=5, random_state=42, n_jobs=-1)
print(f"Spectral cutoff: {cv.spectral_cutoff}")
print(f"Selected model rank: {cv.model_rank}")
cv.fold_scores holds the fold-level validation errors. cv.rank_scores
holds the mean validation error per rank.
Constructing similarity matrices from features¶
SRF needs a symmetric, non-negative (ideally positive semi-definite) similarity matrix. When you start from feature vectors rather than direct pairwise judgments, you turn them into similarities with a kernel. Which kernel to use depends on whether the features are non-negative or signed (paper §4.3).
For non-negative features (for example, count-like or rectified
activations), a linear kernel S = X @ X.T already yields a non-negative
matrix. Optionally rescale it to [0, 1] by dividing by its maximum.
import numpy as np
from pysrf import SRF
rng = np.random.default_rng(42)
x = rng.random((100, 25)) # non-negative features
s = x @ x.T
s = s / s.max() # optional: rescale to [0, 1]
w = SRF(rank=10, random_state=42).fit_transform(s)
For signed features (for example, z-scored neural responses or
centered DNN activations), use an RBF kernel, which is always
non-negative. Set the bandwidth with the median heuristic: sigma is
0.4 times the median pairwise Euclidean distance, a multiplier shown to
work well for symmetric NMF.
import numpy as np
from scipy.spatial.distance import pdist, squareform
from sklearn.metrics.pairwise import rbf_kernel
from pysrf import SRF
rng = np.random.default_rng(42)
x = rng.standard_normal((100, 25)) # signed features
# Median-heuristic bandwidth: sigma = 0.4 * median pairwise distance
median_distance = np.median(pdist(x, metric="euclidean"))
sigma = 0.4 * median_distance
# rbf_kernel uses gamma = 1 / (2 * sigma**2)
gamma = 1.0 / (2.0 * sigma**2)
s = rbf_kernel(x, gamma=gamma)
w = SRF(rank=10, random_state=42).fit_transform(s)
squareform(pdist(x)) gives the full distance matrix if you want to
inspect it; only the median of the pairwise distances is needed to set the
bandwidth.
Consensus embeddings¶
The SRF objective is non-convex, so different random initializations can
converge to different solutions. Part of this is just permutation
ambiguity (the same dimensions in a different order), but runs can also
land in distinct local minima. To get a stable embedding, fit many runs
and align them. EnsembleFit runs the base estimator from several random
starts and stacks the embeddings; AlignedConsensus aligns the runs with
the Hungarian algorithm and returns the most central one.
import numpy as np
from sklearn.pipeline import Pipeline
from pysrf import SRF, EnsembleFit, AlignedConsensus, cross_val_score
rng = np.random.default_rng(42)
n, rank = 100, 10
w_true = rng.random((n, rank))
s = w_true @ w_true.T
cv = cross_val_score(s, range(1, 21), random_state=42)
pipe = Pipeline(
[
(
"ensemble",
EnsembleFit(SRF(rank=cv.model_rank, random_state=42), n_runs=50),
),
("consensus", AlignedConsensus(rank=cv.model_rank)),
]
)
consensus_embedding = pipe.fit_transform(s)
PySRF reports this representative run rather than averaging the runs, because an averaged embedding need not be a valid SRF solution and can distort the reconstructed similarities.
Value bounds¶
When the similarity measure has a known range, you can constrain the
reconstruction to stay inside it. For example, the triplet odd-one-out
similarities in the paper are bounded in [0, 1]; pass bounds=(0, 1) so
the reconstructed entries respect that range.
import numpy as np
from pysrf import SRF
rng = np.random.default_rng(42)
n, rank = 100, 10
w_true = rng.random((n, rank))
s = w_true @ w_true.T
s = s / s.max() # bring into [0, 1]
model = SRF(rank=10, bounds=(0, 1), random_state=42)
w = model.fit_transform(s)
s_reconstructed = model.reconstruct()
assert s_reconstructed.min() >= 0
assert s_reconstructed.max() <= 1
Complete workflow¶
A full analysis ties the pieces together: build a noisy, incomplete similarity matrix, estimate the rank, confirm it with cross-validation, fit the final model, reconstruct the missing entries, and score the fit on observed entries.
import numpy as np
from pysrf import SRF, cross_val_score
# 1. Build a noisy, incomplete similarity matrix
rng = np.random.default_rng(42)
n, true_rank = 100, 8
w_true = rng.random((n, true_rank))
s = w_true @ w_true.T
s += 0.1 * rng.standard_normal((n, n))
s = (s + s.T) / 2 # keep it symmetric
mask = np.triu(rng.random((n, n)) < 0.2, k=1)
mask = mask | mask.T
s[mask] = np.nan # ~20% missing
# 2. Select the model rank by calibrated cross-validation
cv = cross_val_score(
s,
range(1, 21),
n_repeats=5,
random_state=42,
n_jobs=-1,
)
print(f"Spectral cutoff: {cv.spectral_cutoff}; model rank: {cv.model_rank}")
# 3. Fit the final model at the chosen rank
model = SRF(rank=cv.model_rank, random_state=42)
w = model.fit_transform(s)
s_completed = model.reconstruct()
# 4. Score the fit on observed entries (negative MSE, higher is better)
print(f"Score (negative MSE): {model.score(s):.4f}")