moncefem
Memory-LoRA hypernetwork for Gemma-4-E2B: code, curated data, sixview checkpoints
481fbb6
Raw
History Blame Contribute Delete
9.56 kB
#!/usr/bin/env python3
"""Frozen document encoder for the Memory-LoRA hypernetwork.
Forked from Code2LoRA's ``create_dataset/embed_repos.py`` chunk/pool
pipeline (mean-pool chunks -> file vector -> weighted-mean+max repo vector),
retargeted from "repo files" to "document sections":
* Code2LoRA: file_i -> chunks -> mean-pool -> file vector
repo -> weighted-mean+max over file vectors
* Memory-LoRA: doc_section_i -> chunks -> mean-pool -> section vector
doc -> weighted-mean+max over section vectors
Same frozen encoder as the paper (Qwen3-Embedding-0.6B), same reasoning for
the weighting (content-distinctiveness via cosine-distance-from-mean +
log-size normalization) -- multi-section documents (e.g. the Code2LoRA paper
chunked into abstract/method/results/limitations) benefit from it exactly
the way multi-file repos did. Single-section synthetic fact-sheets degenerate
gracefully to a near-uniform weighting over their own chunks.
No gradient ever flows through this encoder; embeddings are precomputed once
and cached to parquet by ``scripts/build_doc_embeddings.py``.
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
import torch
import torch.nn.functional as F
from transformers import AutoModel, AutoTokenizer
DEFAULT_EMBED_MODEL = "Qwen/Qwen3-Embedding-0.6B"
# Section-name heuristics (loose analogue of Code2LoRA's path up/down-weight
# lists). Neutral by default for synthetic single-section documents; the
# upweighted names matter for the multi-section Code2LoRA-paper document.
SECTION_UPWEIGHT = [
r"abstract", r"result", r"conclusion", r"contribution",
]
SECTION_DOWNWEIGHT = [
r"acknowledg", r"reference", r"appendix",
]
MIN_CHARS_FOR_FULL_WEIGHT = 200 # sections shorter than this are downweighted
# ---------------------------------------------------------------------------
# Chunking
# ---------------------------------------------------------------------------
def chunk_token_ids(token_ids: List[int], chunk_tokens: int, overlap: int) -> List[List[int]]:
"""Produce overlapping token windows (identical to Code2LoRA's version)."""
if chunk_tokens <= 0:
raise ValueError("chunk_tokens must be > 0")
if overlap >= chunk_tokens:
raise ValueError("chunk_overlap must be < chunk_tokens")
chunks: List[List[int]] = []
step = chunk_tokens - overlap
n = len(token_ids)
if n == 0:
return chunks
for start in range(0, n, step):
end = min(start + chunk_tokens, n)
window = token_ids[start:end]
if len(window) < 16:
continue
chunks.append(window)
if end >= n:
break
return chunks
# ---------------------------------------------------------------------------
# Embedding model wrapper
# ---------------------------------------------------------------------------
@torch.inference_mode()
def embed_texts(
model: AutoModel,
tokenizer: AutoTokenizer,
texts: List[str],
device: str,
batch_size: int,
max_length: int,
) -> torch.Tensor:
"""Return embeddings [N, D] using mean pooling over last_hidden_state."""
all_vecs = []
for i in range(0, len(texts), batch_size):
batch = texts[i:i + batch_size]
enc = tokenizer(
batch, padding=True, truncation=True,
max_length=max_length, return_tensors="pt",
)
enc = {k: v.to(device) for k, v in enc.items()}
out = model(**enc)
last = out.last_hidden_state # [B, T, H]
mask = enc["attention_mask"].unsqueeze(-1) # [B, T, 1]
mean = (last * mask).sum(dim=1) / mask.sum(dim=1).clamp(min=1)
all_vecs.append(mean.detach().cpu())
if not all_vecs:
return torch.empty((0, model.config.hidden_size))
return torch.cat(all_vecs, dim=0)
# ---------------------------------------------------------------------------
# Pooling: chunks -> section -> document
# ---------------------------------------------------------------------------
def pool_section_embeddings(chunk_embs: torch.Tensor) -> Optional[torch.Tensor]:
"""chunk_embs [K, D] -> section_emb [D]"""
if chunk_embs.numel() == 0:
return None
return chunk_embs.mean(dim=0)
def _section_name_bonus(section_name: str) -> float:
s = section_name.lower()
bonus = 0.0
for pat in SECTION_DOWNWEIGHT:
if re.search(pat, s):
bonus -= 0.25
break
for pat in SECTION_UPWEIGHT:
if re.search(pat, s):
bonus += 0.15
break
return bonus
def compute_section_weights(
section_embs: torch.Tensor, # [S, D]
section_char_counts: torch.Tensor, # [S]
section_names: List[str],
a_distinct: float,
b_size: float,
tau: float,
) -> torch.Tensor:
"""
Whole-doc, all-sections weighting:
distinct_i = 1 - cos(s_i, mean_s)
size_i = normalized log(1+chars)
score_i = a_distinct * distinct_i + b_size * size_i + name_bonus_i + tiny_section_penalty
w = softmax(score / tau)
Returns: w [S] sum=1
"""
f_norm = F.normalize(section_embs, p=2, dim=-1)
mean_f = F.normalize(f_norm.mean(dim=0, keepdim=True), p=2, dim=-1)
cos = (f_norm * mean_f).sum(dim=-1).clamp(-1, 1)
distinct = 1.0 - cos
chars = section_char_counts.float().clamp(min=1)
log_chars = torch.log1p(chars)
if log_chars.numel() > 1:
lo, hi = log_chars.min(), log_chars.max()
size01 = (log_chars - lo) / (hi - lo + 1e-8)
else:
size01 = torch.ones_like(log_chars)
name_bonus = torch.tensor([_section_name_bonus(n) for n in section_names],
dtype=torch.float32)
tiny_scale = (chars / float(MIN_CHARS_FOR_FULL_WEIGHT)).clamp(max=1.0)
tiny_bonus = torch.log(tiny_scale + 1e-6)
score = (a_distinct * distinct.cpu() + b_size * size01.cpu()
+ name_bonus + 0.15 * tiny_bonus.cpu())
return torch.softmax(score / max(tau, 1e-6), dim=0)
def pool_doc_embedding_weighted(
section_embs: torch.Tensor, # [S, D]
section_char_counts: torch.Tensor, # [S]
section_names: List[str],
a_distinct: float = 1.0,
b_size: float = 0.5,
tau: float = 0.5,
alpha_mean: float = 1.0,
beta_max: float = 1.0,
) -> Optional[torch.Tensor]:
"""Aggregate section embeddings into one document vector:
concat(alpha_mean * weighted_mean, beta_max * max) -> [2D]. No final
L2 normalization (matches Code2LoRA's repo-vector convention)."""
if section_embs.numel() == 0:
return None
w = compute_section_weights(
section_embs, section_char_counts, section_names,
a_distinct, b_size, tau,
).to(section_embs.dtype)
wmean = (section_embs * w.unsqueeze(-1)).sum(dim=0)
vmax = section_embs.max(dim=0).values
return torch.cat([alpha_mean * wmean, beta_max * vmax], dim=0)
# ---------------------------------------------------------------------------
# Main pipeline per document
# ---------------------------------------------------------------------------
def embed_document(
sections: List[Tuple[str, str]], # [(section_name, section_text), ...]
model: AutoModel,
tokenizer: AutoTokenizer,
device: str,
chunk_tokens: int = 4096,
chunk_overlap: int = 512,
batch_size: int = 4,
a_distinct: float = 1.0,
b_size: float = 0.5,
tau: float = 0.5,
alpha_mean: float = 1.0,
beta_max: float = 1.0,
) -> Optional[torch.Tensor]:
"""One document = list of (name, text) sections (single-element for a
plain synthetic fact-sheet; multi-element for the chunked paper).
Returns a [2D] embedding, or None if the document had no usable text."""
section_vectors: List[torch.Tensor] = []
section_names: List[str] = []
section_char_counts: List[int] = []
for name, text in sections:
text = (text or "").strip()
if not text:
continue
ids = tokenizer.encode(text, add_special_tokens=False)
windows = chunk_token_ids(ids, chunk_tokens=chunk_tokens, overlap=chunk_overlap)
if not windows:
continue
chunks = [tokenizer.decode(w, skip_special_tokens=True) for w in windows]
chunk_embs = embed_texts(
model=model, tokenizer=tokenizer, texts=chunks,
device=device, batch_size=batch_size, max_length=chunk_tokens,
)
svec = pool_section_embeddings(chunk_embs)
if svec is None:
continue
section_vectors.append(svec)
section_names.append(name)
section_char_counts.append(len(text))
if not section_vectors:
return None
section_embs = torch.stack(section_vectors, dim=0)
char_t = torch.tensor(section_char_counts, dtype=torch.int64)
return pool_doc_embedding_weighted(
section_embs, char_t, section_names,
a_distinct=a_distinct, b_size=b_size, tau=tau,
alpha_mean=alpha_mean, beta_max=beta_max,
)
def load_encoder(model_name: str = DEFAULT_EMBED_MODEL, device: str = "mps"):
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModel.from_pretrained(model_name, torch_dtype=torch.float32)
model.to(device)
model.eval()
return model, tokenizer
__all__ = [
"DEFAULT_EMBED_MODEL",
"chunk_token_ids",
"embed_texts",
"pool_section_embeddings",
"compute_section_weights",
"pool_doc_embedding_weighted",
"embed_document",
"load_encoder",
]