Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
1a9dc63
Remove umap-pytorch fork dependency
ekiefl Dec 11, 2025
45f851e
Add dependencies for vendored parametric umap
ekiefl Dec 11, 2025
e2bc43c
Add seaborn as dev dependency for the mnist script
ekiefl Dec 11, 2025
3c8bbe9
An initial re-implementation of parametric umap_pytorch
ekiefl Dec 11, 2025
fdf65be
Add get_accelerator utility
ekiefl Dec 11, 2025
f4e6dfb
Reorganize parametric_umap
ekiefl Dec 11, 2025
8306060
Delete example module
ekiefl Dec 11, 2025
2cdc6e6
Apply autoformatting
ekiefl Dec 11, 2025
419adeb
Small fixes to typing
ekiefl Dec 11, 2025
61279b4
Verbose and PEP-compliant class names
ekiefl Dec 11, 2025
511f0b3
Doc touchups
ekiefl Dec 11, 2025
43dcb63
Fix dataset size to be equal to number of nodes (not edges)
ekiefl Dec 11, 2025
508ff24
Update dev and doc deps
ekiefl Dec 11, 2025
e47dc7e
Add mnist.py to examples/
ekiefl Dec 11, 2025
25c58e5
Teardown glass_box_umap.py
ekiefl Dec 12, 2025
186684e
Remove device as argument from loss fn
ekiefl Dec 12, 2025
a645883
Remove batch_size as arg of loss fn
ekiefl Dec 12, 2025
7a06d21
Remove decoder, raise notimplementederror
ekiefl Dec 12, 2025
7931e3c
No logging/checkpointing without checkpoint_dir set
ekiefl Dec 12, 2025
7faef09
More permissive type checking, exclude docs/
ekiefl Dec 12, 2025
2dc2162
Axe the decoder initialization
ekiefl Dec 12, 2025
61e9dd9
device is not an attribute
ekiefl Dec 23, 2025
32bd964
Trialing dataclass composition over flat god constructor
ekiefl Dec 27, 2025
07f06c8
Fix all type errors in graph.py
ekiefl Dec 29, 2025
008267b
Store input dims in lightning module, add neg sampling rate
ekiefl Dec 29, 2025
d51f01b
Generalize the default models
ekiefl Dec 29, 2025
dd28550
Add ConvEncoder to registry
ekiefl Dec 29, 2025
9475f5c
Add reproducibility and refit tests
ekiefl Dec 29, 2025
db17ae2
Flatten ParametricUMAP config into single dataclass
ekiefl Dec 29, 2025
3cf70cf
Repulsion strength prop drill
ekiefl Dec 29, 2025
b472593
Add batched, device-aware transform
ekiefl Dec 29, 2025
58a0c4a
Add _fitted_model property
ekiefl Dec 29, 2025
127ffb2
Refactor GlassBoxUMAP to inherit from ParametricUMAP
ekiefl Dec 29, 2025
d487f93
Add DeepPReLUNet back to components
ekiefl Dec 30, 2025
f0198a1
Ignore type
ekiefl Dec 30, 2025
1e2433e
Restrict typecheck to src/ and tests/
ekiefl Dec 30, 2025
4b002e7
Update example
ekiefl Dec 30, 2025
c78e57f
Add dropout
ekiefl Jan 9, 2026
18a7f1f
Track and load best model as final state; tensorboard logging
ekiefl Jan 9, 2026
fc36371
Propagate repulsion strength
ekiefl Jan 9, 2026
d2ce190
Add tensorboard dep
ekiefl Jan 9, 2026
4e1b3be
Update default encoder hidden sizes
ekiefl Jan 9, 2026
df22b54
Add scripts/
ekiefl Jan 9, 2026
225a404
Only ruff on ipynbs (global) and pys (in src/)
ekiefl Feb 4, 2026
5d8be94
Update tests to reflect dropout
ekiefl Feb 4, 2026
9441267
Merge branch 'main' into ek/refactor
ekiefl Feb 4, 2026
b25fd96
Establish reproduction of MNIST test artifacts
ekiefl Feb 4, 2026
0d2b9b2
tests/ is also subject to linting
ekiefl Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ format:

.PHONY: typecheck
typecheck:
pyright --project pyproject.toml .
pyright --project pyproject.toml src/ tests/

.PHONY: pre-commit
pre-commit:
Expand Down
109 changes: 40 additions & 69 deletions docs/examples/mnist.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,95 +20,67 @@
from sklearn.datasets import fetch_openml
from sklearn.decomposition import PCA

from glass_box_umap.utils import get_accelerator

app = typer.Typer(pretty_exceptions_enable=False)


def load_and_preprocess_mnist(n_pcs: int, subset: int = 4000) -> tuple[np.ndarray, np.ndarray, PCA]:
def load_and_preprocess_mnist(
n_pcs: int, subset: int = 4000
) -> tuple[torch.Tensor, np.ndarray, np.ndarray, PCA]:
"""Fetches MNIST data and performs PCA preprocessing."""
print("Fetching MNIST...")
mnist = fetch_openml("mnist_784", version=1)
data_values = mnist.data.values[:subset, :]
# data_values = (data_values.T - data_values.mean(axis=1).T).T
data_raw = mnist.data.values[:subset, :].astype(np.float32)
target_values = mnist.target.values[:subset]

data_centered = data_raw - data_raw.mean(axis=0)

pca = PCA(n_components=n_pcs)
pca.fit(data_values)
pca.fit(data_raw)
data_pca = pca.transform(data_raw).astype(np.float32)

mnist_pca = pca.transform(data_values)
mnist_pca_centered = (mnist_pca.T - mnist_pca.mean(axis=1)).T
return mnist_pca_centered, target_values, pca
return torch.from_numpy(data_pca), target_values, data_centered, pca


@app.command()
def main(
output_dir: Path = typer.Option(Path("output"), help="Directory for all output files"),
n_fits: int = typer.Option(1, help="Number of UMAP fits"),
n_pcs: int = typer.Option(100, help="Number of PCA components"),
epochs: int = typer.Option(50, help="Number of training epochs"),
n_pcs: int = typer.Option(25, help="Number of PCA components"),
epochs: int = typer.Option(100, help="Number of training epochs"),
random_state: int = typer.Option(42, help="Random seed"),
hidden_size: int = typer.Option(512*2, help="Hidden layer size"),
hidden_size: int = typer.Option(512, help="Hidden layer size"),
train: bool = typer.Option(True, help="Train model (False to load from disk)"),
) -> None:
"""Train GlassBoxUMAP on MNIST and save outputs."""
output_dir.mkdir(parents=True, exist_ok=True)
model_path_pattern = str(output_dir / "models" / "umap_{i}.pth")
(output_dir / "models").mkdir(parents=True, exist_ok=True)

accelerator = get_accelerator()

if accelerator in ("cuda", "mps"):
subset = 20000
batch_size = 192
model_path = output_dir / "model.pt"

X_pca, y_target, X_centered, pca_model = load_and_preprocess_mnist(n_pcs)

if train:
print("Initializing GlassBoxUMAP...")
reducer = GlassBoxUMAP(
epochs=epochs,
random_state=random_state,
encoder_kwargs={"hidden_size": hidden_size},
)

print("Fitting GlassBoxUMAP...")
reducer.fit(X_pca)
reducer.save(model_path)
print(f"Saved model to {model_path}")
else:
subset = 4000
batch_size = 4096

X_pca_centered, y_target, pca_model = load_and_preprocess_mnist(n_pcs, subset=subset)

print("Initializing GlassBoxUMAP...")
reducer = GlassBoxUMAP(
n_fits=n_fits,
epochs=epochs if train else 0,
random_state=random_state,
input_size=n_pcs,
hidden_size=hidden_size,
min_dist=0.1,
lr=4e-4,
batch_size=batch_size
print(f"Loading model from {model_path}...")
reducer = GlassBoxUMAP.load(model_path)

print("Computing attributions...")
feature_contributions, jacobians = reducer.compute_attributions(
X=X_pca,
raw_features=X_centered,
projector=pca_model.components_.T,
)

print("Fitting GlassBoxUMAP...")
reducer.fit(
X_pca_centered,
load_models=not train,
load_n_fits=n_fits,
save_models=train,
model_path_pattern=model_path_pattern,
)

print("Running manual Jacobian check...")
model = reducer._models[0]
encoder = model.encoder
device = reducer._device
encoder.eval().to(device)

sample_tensor = torch.tensor(X_pca_centered[:1, :].squeeze(), dtype=torch.float32).to(device)

jac_batch = torch.autograd.functional.jacobian(
encoder, sample_tensor, vectorize=True, strategy="reverse-mode"
)

jacobian_np = jac_batch.T.detach().cpu().numpy().T
reconstruction = jacobian_np @ X_pca_centered[0, :]
print("Jacobian Reconstruction:", reconstruction)

forward_pass = encoder(torch.tensor(X_pca_centered[0, :], dtype=torch.float32).to(device))
print("Forward Pass:", forward_pass)

print("Plotting embedding...")
embedding = reducer._embeddings[0]
embedding = reducer.transform(X_pca)

sns.set_style("whitegrid")
ax = sns.scatterplot(x=embedding[:, 0], y=embedding[:, 1], hue=y_target, s=3)
Expand All @@ -119,12 +91,11 @@ def main(
fig.savefig(embedding_path, bbox_inches="tight")
print(f"Saved embedding plot to {embedding_path}")

print("Plotting linear operator...")
print("Plotting linear operator for first sample...")
plt.figure()

linear_operator = (pca_model.components_.T @ jac_batch.T.detach().cpu().numpy())[:, 0].reshape(
[28, 28]
)
jacobian_pixel = jacobians[0].numpy() @ pca_model.components_
linear_operator = jacobian_pixel[0, :].reshape([28, 28])

plt.imshow(linear_operator, cmap="RdBu")
linear_op_path = output_dir / "linear_operator.png"
Expand Down
12 changes: 9 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies = [
"umap-learn>=0.5.7",
"pynndescent>=0.5.13",
"scikit-learn>=1.6.0",
"tensorboard>=2.20.0",
]

[[tool.uv.index]]
Expand Down Expand Up @@ -78,9 +79,9 @@ docs = [
[tool.ruff]
# The directories to consider when resolving first- vs. third-party imports.
src = ["."]
include = ["src/**/*.py", "tests/**/*.py", "**/*.ipynb"]
line-length = 100
indent-width = 4
extend-include = ["*.ipynb"]

[tool.ruff.lint.per-file-ignores]
# Ignore star and unused imports.
Expand Down Expand Up @@ -109,10 +110,15 @@ line-ending = "auto"
order-by-type = true
no-lines-before = ["future", "standard-library"]

[tool.pytest.ini_options]
filterwarnings = [
"ignore:The 'train_dataloader' does not have many workers:UserWarning",
]

[tool.pyright]
typeCheckingMode = "basic"
exclude = ["./.venv", "./dist"]
exclude = ["./.venv", "./dist", "./docs"]

# Pyright reports a lot of unknown-member errors for some packages.
# If this is a problem, set this to `false`.
reportUnknownMemberType = true
reportUnknownMemberType = false
56 changes: 56 additions & 0 deletions scripts/visualize_mnist_embeddings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from pathlib import Path

import matplotlib.pyplot as plt
import torch
from glass_box_umap import GlassBoxUMAP, ParametricUMAP
from umap import UMAP

X = torch.load("./tests/fixtures/mnist_images.pt")
y = torch.load("./tests/fixtures/mnist_labels.pt")

color_dict = {
0: "red",
1: "green",
2: "blue",
3: "black",
4: "magenta",
5: "cyan",
6: "orange",
7: "salmon",
8: "violet",
9: "purple",
}
colors = [color_dict.get(x.item(), "grey") for x in y]

reducer = UMAP()
reducer.fit(X)
output = reducer.transform(X)

plt.scatter(output[:, 0], output[:, 1], c=colors, s=5)
plt.savefig("umap_original.png")
plt.close()

for epoch in [1, 5, 10, 50, 100, 200, 500]:
reducer = ParametricUMAP(
epochs=epoch,
lr=1e-3,
batch_size=256,
repulsion_strength=1.0,
encoder_name="default",
checkpoint_dir=Path(f"tmp_{epoch}"),
)
# reducer = GlassBoxUMAP(
# epochs=epoch,
# lr=1e-3,
# batch_size=256,
# repulsion_strength=1.0,
# encoder_kwargs={"hidden_size": 1024},
# checkpoint_dir=Path(f"tmp_{epoch}"),
# )

reducer.fit(X)
output = reducer.transform(X)

plt.scatter(output[:, 0], output[:, 1], c=colors, s=5)
plt.savefig(f"umap_glassbox_{epoch}.png")
plt.close()
3 changes: 1 addition & 2 deletions src/glass_box_umap/__init__.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
from .core import GlassBoxUMAP
from .parametric_umap import ParametricUMAP, load_pumap
from .parametric_umap import ParametricUMAP

__all__ = [
"GlassBoxUMAP",
"ParametricUMAP",
"load_pumap",
]
60 changes: 60 additions & 0 deletions src/glass_box_umap/components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
from __future__ import annotations
import math

import torch
from torch import nn


class LayerNormDetached(nn.Module):
"""A LayerNorm with detached variance calculation during evaluation."""

def __init__(self, emb_dim: int):
super().__init__()
self.scale = nn.Parameter(torch.ones(emb_dim))

def forward(self, x: torch.Tensor) -> torch.Tensor:
mean = x.mean(dim=-1, keepdim=True)

# Detach variance calculation during evaluation
if not self.training:
var = x.clone().detach().var(dim=-1, keepdim=True, unbiased=False)
else:
var = x.var(dim=-1, keepdim=True, unbiased=False)

norm_x = (x - mean) / torch.sqrt(var + 1e-12) # Added epsilon for stability
return self.scale * norm_x


class DeepPReLUNet(nn.Module):
"""A network with PReLU activation and LayerNormDetached."""

def __init__(
self,
input_dims: tuple[int, ...],
n_components: int = 2,
hidden_size: int = 256,
n_hidden_layers: int = 5,
dropout_rate: float = 0.1,
):
super().__init__()

input_size = math.prod(input_dims)
self.flatten = nn.Flatten()

layers = []
for i in range(n_hidden_layers):
in_dim = input_size if i == 0 else hidden_size

layers.append(nn.Linear(in_dim, hidden_size, bias=False))
layers.append(nn.PReLU())

if i < n_hidden_layers - 1:
layers.append(LayerNormDetached(hidden_size))

layers.append(nn.Dropout(dropout_rate))

layers.append(nn.Linear(hidden_size, n_components, bias=False))
self.model = nn.Sequential(*layers)

def forward(self, x: torch.Tensor) -> torch.Tensor:
return self.model(self.flatten(x))
Loading
Loading