Skip to content

Commit 60ada76

Browse files
committed
Support noised text recall and metrics
Add CLI --noise option and support for evaluating recall on noised text embeddings. test_recall now accepts use_noise, can load noised embeddings, and writes results to noise-aware recognition directories/filenames. Improve text noise handling by adding get_random_replacement_char and allowing random-character replacement when character_mask is None. Add Euclidean metric (and mean_euclidean) to text reconstruction metrics and include it in summaries and get_besttext_params outputs. Increase decode_text_embeddings batch size from 32 to 64. Declare ipywidgets (and related jupyter widget packages) in pyproject.toml and update the lockfile to include those packages.
1 parent 9d8c559 commit 60ada76

5 files changed

Lines changed: 99 additions & 19 deletions

File tree

‎manage.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,14 +319,24 @@ def test_classifier_command(config):
319319
# Memory Commands
320320
@cli.command()
321321
@click.option("--config", help="YAML configuration.")
322-
def test_recall(config):
322+
@click.option(
323+
"--noise",
324+
is_flag=True,
325+
help="Use noised test embeddings if available for text recall evaluation.",
326+
)
327+
def test_recall(config, noise):
323328
"🔍 Test recall with distinct params"
324329
cfg = load_cli_config(config)
325330
config_summary(cfg)
326331

327332
if cfg.app.modality == "text":
328333
from reamember.pipes.text import test_recall
329-
test_recall(cfg, device=device, experiments_root=EXPERIMENTS_ROOT)
334+
test_recall(
335+
cfg,
336+
device=device,
337+
experiments_root=EXPERIMENTS_ROOT,
338+
use_noise=noise,
339+
)
330340
return
331341
else:
332342
raise NotImplementedError("Recall testing is only implemented for text modality.")

‎pyproject.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,4 +27,5 @@ dependencies = [
2727
"tqdm>=4.67.3",
2828
"nltk>=3.9.4",
2929
"gradio>=6.14.0",
30+
"ipywidgets>=8.1.8",
3031
]

‎reamember/pipes/text.py‎

Lines changed: 49 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pandas as pd
44
import json
55
import math
6+
import string
67
from nltk.metrics import edit_distance
78
from tqdm import tqdm
89
from plotly import graph_objects as go
@@ -63,7 +64,19 @@ def normalize_noise_level(noise_level):
6364
return min(noise_level, 1.0)
6465

6566

66-
def apply_text_noise(text, noise_level=0.1, character_mask='_'):
67+
def get_random_replacement_char(original_char):
68+
if str(original_char).isupper():
69+
alphabet = string.ascii_uppercase
70+
original = str(original_char).upper()
71+
else:
72+
alphabet = string.ascii_lowercase
73+
original = str(original_char).lower()
74+
75+
candidates = [char for char in alphabet if char != original]
76+
return str(np.random.choice(candidates))
77+
78+
79+
def apply_text_noise(text, noise_level=0.1, character_mask=None):
6780
text = str(text).strip()
6881
if not text:
6982
return text
@@ -95,21 +108,35 @@ def apply_text_noise(text, noise_level=0.1, character_mask='_'):
95108

96109
masked_text = list(text)
97110
for position in selected_positions:
98-
masked_text[position] = character_mask
111+
masked_text[position] = (
112+
get_random_replacement_char(masked_text[position])
113+
if character_mask is None
114+
else character_mask
115+
)
99116

100117
return ''.join(masked_text)
101118

102119

103-
def test_recall(cfg, device, experiments_root):
120+
def test_recall(cfg, device, experiments_root, use_noise=False):
104121

105122
latent = cfg.neural.latent_dim[0] if isinstance(cfg.neural.latent_dim, ListConfig) else int(cfg.neural.latent_dim)
106123

107124
path = get_experiment_path(cfg, experiments_root, latent)
108125
embeddings_dataset = load_embeddings_dataset(path, device=device)
126+
test_embeddings_dataset = embeddings_dataset
127+
recognition_dirname = "recognition"
128+
129+
if use_noise:
130+
test_embeddings_dataset = load_embeddings_dataset(
131+
path,
132+
device=device,
133+
noised=True,
134+
)
135+
recognition_dirname = f"recognition_noise_{cfg.app.noise}"
109136

110137
# First Experiment: Test Recognition Rates
111138

112-
recognition_text_path = ensure_directory(path / "recognition")
139+
recognition_text_path = ensure_directory(path / recognition_dirname)
113140

114141
quantizer = Quant(
115142
torch.cat([embeddings_dataset.train.data, embeddings_dataset.test.data], dim=0)
@@ -141,7 +168,10 @@ def test_recall(cfg, device, experiments_root):
141168
else [float(cfg.memory.sigma)]
142169
)
143170

144-
print(f"[INFO] Testing recall with latent={latent}, domains={domains}, sigmas={sigmas}, xis={xis}, iotas={iotas}, kappas={kappas}")
171+
print(
172+
f"[INFO] Testing recall with latent={latent}, domains={domains}, sigmas={sigmas}, "
173+
f"xis={xis}, iotas={iotas}, kappas={kappas}, use_noise={use_noise}"
174+
)
145175

146176
train_embeddings = embeddings_dataset.train.data
147177
split_index = max(1, len(train_embeddings) // 2) # Split train into two halves for seen/unseen evaluation, ensuring at least one sample in the seen half
@@ -152,7 +182,7 @@ def test_recall(cfg, device, experiments_root):
152182

153183
confusion_summaries = [] # Prepare to collect confusion summaries for all domains
154184
confusion_summary_path = (
155-
recognition_text_path / "recognition_confusion_summary.json"
185+
recognition_text_path / f"recognition_confusion_summary_{use_noise}.json"
156186
)
157187

158188
from rich.progress import (
@@ -223,7 +253,7 @@ def test_recall(cfg, device, experiments_root):
223253
eam,
224254
seen_dataset=memory_wrapper.train,
225255
unseen_dataset=memory_wrapper.test,
226-
test_dataset=embeddings_dataset.test,
256+
test_dataset=test_embeddings_dataset.test,
227257
quantizer=quantizer,
228258
batch_size=batch_size,
229259
)
@@ -238,15 +268,14 @@ def test_recall(cfg, device, experiments_root):
238268
"xi": xi,
239269
"seen_source": "first_half_of_train",
240270
"unseen_source": "second_half_of_train",
241-
"test_source": "dataset_test_split",
242271
"labels": confusion["labels"],
243272
"matrix": confusion["matrix"].tolist(),
244273
"counts": confusion["counts"],
245274
"rates": confusion["rates"],
246275
}
247276

248277
confusion_path = (
249-
recognition_text_path / f"recognition_confusion_domain_{domain}_sigma_{sigma}_xi_{xi}_iota_{iota}_kappa_{kappa}.json"
278+
recognition_text_path / f"recognition_confusion_domain_{domain}_sigma_{sigma}_xi_{xi}_iota_{iota}_kappa_{kappa}_{test_source}.json"
250279
)
251280

252281
with open(confusion_path, "w", encoding="utf-8") as f_out:
@@ -633,17 +662,18 @@ def text_reconstruction_metrics(
633662
reconstructed_embeddings = torch.cat(reconstructed_embeddings, dim=0)
634663
source_embeddings = source_embeddings.detach().cpu()
635664

636-
source_norm = torch.linalg.norm(source_embeddings, dim=1)
637-
reconstructed_norm = torch.linalg.norm(reconstructed_embeddings, dim=1)
638-
denom = torch.clamp(source_norm * reconstructed_norm, min=1e-12)
665+
source_norm = torch.linalg.norm(source_embeddings, dim=1) # Compute norms for cosine similarity
666+
reconstructed_norm = torch.linalg.norm(reconstructed_embeddings, dim=1) # Compute norms for cosine similarity
667+
denom = torch.clamp(source_norm * reconstructed_norm, min=1e-12) # Avoid division by zero in cosine similarity calculation
639668
cosine_scores = (
640669
torch.sum(source_embeddings * reconstructed_embeddings, dim=1) / denom
641-
)
642-
l2_scores = torch.linalg.norm(source_embeddings - reconstructed_embeddings, dim=1)
670+
) # Get cosine similarity scores between source and reconstructed embeddings
671+
l2_scores = torch.linalg.norm(source_embeddings - reconstructed_embeddings, dim=1) # Get L2 distance scores between source and reconstructed embeddings
672+
euclidean_scores = torch.sqrt(torch.sum((source_embeddings - reconstructed_embeddings) ** 2, dim=1)) # Get Euclidean distance scores between source and reconstructed embeddings
643673

644674
samples = []
645-
for index, (original, cue, reconstructed, cosine, l2) in enumerate(
646-
zip(original_texts, cue_texts, reconstructed_texts, cosine_scores, l2_scores)
675+
for index, (original, cue, reconstructed, cosine, l2, euclidean) in enumerate(
676+
zip(original_texts, cue_texts, reconstructed_texts, cosine_scores, l2_scores, euclidean_scores)
647677
):
648678
samples.append(
649679
{
@@ -652,6 +682,7 @@ def text_reconstruction_metrics(
652682
"cue": cue,
653683
"reconstructed": reconstructed,
654684
"cosine": float(cosine.item()),
685+
"euclidean": float(euclidean.item()),
655686
"l2": float(l2.item()),
656687
"edit_distance": int(
657688
edit_distance(original.lower(), reconstructed.lower())
@@ -662,6 +693,7 @@ def text_reconstruction_metrics(
662693
summary = {
663694
"samples": len(samples),
664695
"mean_cosine": float(np.mean([item["cosine"] for item in samples])),
696+
"mean_euclidean": float(np.mean([item["euclidean"] for item in samples])),
665697
"mean_l2": float(np.mean([item["l2"] for item in samples])),
666698
"mean_edit_distance": float(
667699
np.mean([item["edit_distance"] for item in samples])
@@ -851,6 +883,7 @@ def get_besttext_params(cfg, config, device, EXPERIMENTS_ROOT, use_noise=False):
851883
"recognized": recognized,
852884
"unrecognized": unrecognized,
853885
"mean_cosine": summary["mean_cosine"],
886+
"mean_euclidean": summary["mean_euclidean"],
854887
"mean_l2": summary["mean_l2"],
855888
"mean_edit_distance": summary["mean_edit_distance"],
856889
}

‎reamember/utils.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ def load_model_state(model, model_path, label, device, move_to_device=True):
177177
return model
178178

179179

180-
def decode_text_embeddings(model, embeddings, device=None, batch_size=32):
180+
def decode_text_embeddings(model, embeddings, device=None, batch_size=64):
181181
"""
182182
Decode embeddings into text batches using SONAR.
183183
"""

‎uv.lock‎

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)