Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
## 2025-05-15 - Vectorizing Euclidean Distance Calculation
**Learning:** Using the expansion formula $||a-b||^2 = ||a||^2 + ||b||^2 - 2ab$ for vectorized distance calculation provides a massive speedup (12x in this case) but can introduce small floating-point discrepancies (negative values) due to subtractive cancellation.
**Action:** Always use `np.maximum(dists_sq, 0)` when using the expansion formula to ensure physical correctness and stability for subsequent operations like `np.sqrt` or `np.exp`.
51 changes: 51 additions & 0 deletions extra/benchmark_estimator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import time
import numpy as np
from face_engine.models.basic_estimator import BasicEstimator

def benchmark():
# Simulate fitted data: 2000 persons with 128-dim embeddings
n_fitted = 2000
dim = 128
fitted_embeddings = np.random.rand(n_fitted, dim).astype(np.float32)
class_names = [f"person_{i}" for i in range(n_fitted)]

# Simulate queries: 500 faces to recognize
n_queries = 500
query_embeddings = np.random.rand(n_queries, dim).astype(np.float32)

# Original implementation style (manual loop for reference in explanation)
def original_predict(fitted, queries):
scores = []
for q in queries:
distances = np.linalg.norm(fitted - q, axis=1)
index = np.argmin(distances)
score = np.exp(-0.5 * distances[index] ** 2)
scores.append(score)
return scores

# Warm up original
original_predict(fitted_embeddings, query_embeddings[:10])

start_orig = time.time()
original_predict(fitted_embeddings, query_embeddings)
end_orig = time.time()
orig_time = end_orig - start_orig
print(f"Original-style prediction time: {orig_time:.4f}s")

# New implementation
estimator = BasicEstimator()
estimator.fit(fitted_embeddings, class_names)

# Warm up new
estimator.predict(query_embeddings[:10])

start_new = time.time()
scores, names = estimator.predict(query_embeddings)
end_new = time.time()
new_time = end_new - start_new
print(f"Vectorized prediction time: {new_time:.4f}s")

print(f"Speedup: {orig_time / new_time:.2f}x")

if __name__ == "__main__":
benchmark()
40 changes: 31 additions & 9 deletions face_engine/models/basic_estimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,41 @@ class BasicEstimator(Estimator, name="basic"):
def __init__(self):
self.embeddings = None
self.class_names = None
self.fitted_norms_sq = None

def fit(self, embeddings, class_names, **kwargs):
self.embeddings = embeddings
self.embeddings = np.asarray(embeddings)
self.class_names = class_names
# Pre-calculate squared norms of fitted embeddings to speed up prediction
self.fitted_norms_sq = np.sum(self.embeddings**2, axis=1)

def predict(self, embeddings):
if self.class_names is None:
raise TrainError("Model is not fitted yet!")

scores = []
class_names = []
for embedding in embeddings:
distances = np.linalg.norm(self.embeddings - embedding, axis=1)
index = np.argmin(distances)
score = np.exp(-0.5 * distances[index] ** 2)
scores.append(score)
class_names.append(self.class_names[index])
embeddings = np.asarray(embeddings)
if embeddings.size == 0:
return [], []

# Ensure we have a 2D array for matrix operations
if embeddings.ndim == 1:
embeddings = embeddings[np.newaxis, :]

# Vectorized Euclidean distance calculation using expansion formula:
# ||a-b||^2 = ||a||^2 + ||b||^2 - 2ab
q_norms_sq = np.sum(embeddings**2, axis=1, keepdims=True)
# Resulting matrix shape: (n_queries, n_fitted)
dists_sq = q_norms_sq + self.fitted_norms_sq - 2 * np.dot(embeddings, self.embeddings.T)

# Handle potential small negative values due to floating point precision
dists_sq = np.maximum(dists_sq, 0)

# Find nearest neighbor for each query embedding
indices = np.argmin(dists_sq, axis=1)
min_dists_sq = dists_sq[np.arange(len(embeddings)), indices]

scores = np.exp(-0.5 * min_dists_sq).tolist()
class_names = [self.class_names[i] for i in indices]
return scores, class_names

def save(self, dirname):
Expand All @@ -46,3 +64,7 @@ def load(self, dirname):
name = "%s.estimator.%s" % (self.name, "p")
with open(os.path.join(dirname, name), "rb") as file:
self.__dict__.update(pickle.load(file))

# Re-calculate norms if loading from an older version
if self.embeddings is not None and getattr(self, "fitted_norms_sq", None) is None:
self.fitted_norms_sq = np.sum(self.embeddings**2, axis=1)