Native-Bird2 / nbmm /common.py
UncleanCode's picture
Upload via Autoresearch export_data
989d4ef verified
Raw History Blame Contribute Delete
10.8 kB
"""Shared pieces: config, encoders, feature caching, projection heads, loss, retrieval metrics."""
from __future__ import annotations
import os, re, json, random, hashlib
from dataclasses import dataclass
import numpy as np, pandas as pd, torch, torch.nn as nn, torch.nn.functional as F, yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# ----------------------------------------------------------------------------- config
def _set(d, dotted, val):
ks = dotted.split(".")
for k in ks[:-1]:
d = d.setdefault(k, {})
d[ks[-1]] = yaml.safe_load(val) if isinstance(val, str) else val
def _get(d, dotted):
for k in dotted.split("."):
d = d[k]
return d
def load_config(path, overrides=()):
cfg = yaml.safe_load(open(path))
for o in overrides: # key.sub=value
k, v = o.split("=", 1)
_set(cfg, k, v)
s = yaml.safe_dump(cfg)
s = re.sub(r"\$\{([^}]+)\}", lambda m: str(_get(cfg, m.group(1))), s)
return yaml.safe_load(s)
def rel(p):
return p if os.path.isabs(p) else os.path.join(ROOT, p)
def seed_all(s):
random.seed(s); np.random.seed(s); torch.manual_seed(s); torch.cuda.manual_seed_all(s)
def pick_device(name="auto"):
if name != "auto":
return torch.device(name)
if torch.cuda.is_available():
return torch.device("cuda")
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
return torch.device("mps")
return torch.device("cpu")
def safe(name):
return re.sub(r"[^A-Za-z0-9._-]+", "_", name)
# ----------------------------------------------------------------------------- data
def load_split(cfg, split):
df = pd.read_parquet(rel(os.path.join(cfg["data"]["dir"], f"{split}.parquet")))
n = cfg["data"].get("subset")
if n:
df = df.iloc[: int(n)].reset_index(drop=True)
return df
def long_pairs(df, langs):
"""Wide (one row per image, one column per language, NaN = filtered out) -> long (image_idx, lang, text)."""
rows = []
for i, r in df.iterrows():
for l in langs:
t = r.get(l)
if isinstance(t, str) and t.strip():
rows.append((i, l, t))
return pd.DataFrame(rows, columns=["img_idx", "lang", "text"])
# ----------------------------------------------------------------------------- image encoder (frozen)
class ImageEncoder:
def __init__(self, cfg, device):
ic = cfg["image_encoder"]
self.backend, self.name, self.device = ic["backend"], ic["name"], device
if self.backend == "hf":
from transformers import AutoModel, AutoImageProcessor
self.model = AutoModel.from_pretrained(self.name).to(device).eval()
self.proc = AutoImageProcessor.from_pretrained(self.name)
elif self.backend == "open_clip":
import open_clip
arch, pre = self.name.split("/", 1)
self.model, _, self.proc = open_clip.create_model_and_transforms(arch, pretrained=pre)
self.model = self.model.to(device).eval()
else:
raise ValueError(self.backend)
@torch.no_grad()
def encode(self, pil_images):
if self.backend == "hf":
px = self.proc(images=pil_images, return_tensors="pt")["pixel_values"].to(self.device)
out = self.model.get_image_features(pixel_values=px)
if not torch.is_tensor(out): # some versions return a ModelOutput
out = getattr(out, "pooler_output", None) if getattr(out, "pooler_output", None) is not None else out[0]
else:
px = torch.stack([self.proc(im) for im in pil_images]).to(self.device)
out = self.model.encode_image(px)
return out.float().cpu()
def image_features(cfg, df, split, device, batch_size=32):
"""Cached frozen image features for the rows of df, keyed by image_id."""
from PIL import Image
ic = cfg["image_encoder"]
d = rel(os.path.join(ic["cache_dir"], "img_" + safe(ic["name"])))
os.makedirs(d, exist_ok=True)
path = os.path.join(d, "all.pt")
cache = torch.load(path) if os.path.exists(path) else {}
need = [i for i in df.image_id if i not in cache]
if need:
enc = ImageEncoder(cfg, device)
idir = rel(cfg["data"]["image_dir"])
import time
last = time.time()
for s in range(0, len(need), batch_size):
ids = need[s: s + batch_size]
ims = [Image.open(os.path.join(idir, i)).convert("RGB") for i in ids]
for i, f in zip(ids, enc.encode(ims)):
cache[i] = f
if time.time() - last > 20: # checkpoint the cache so an interrupted run resumes
print(f" image feats {s + len(ids)}/{len(need)}", flush=True)
torch.save(cache, path); last = time.time()
torch.save(cache, path)
del enc
return torch.stack([cache[i] for i in df.image_id])
# ----------------------------------------------------------------------------- text encoder
def text_arm(cfg):
tc = cfg["text_encoder"]
a = dict(tc["arms"][tc["arm"]])
if not a.get("name"):
raise ValueError(f"text_encoder.arms.{tc['arm']}.name is empty")
return a
def load_sentence_model(arm, device):
from sentence_transformers import SentenceTransformer
kw = {}
if arm.get("text_only"): # EmbeddingGemma 2: skip vision/audio towers (270M text-only)
kw["config_kwargs"] = {"vision_config": None, "audio_config": None}
if "embeddinggemma" in arm["name"]:
# EmbeddingGemma: bf16 or fp32 only (fp16 overflows)
dt = torch.bfloat16 if (device.type == "cuda" and torch.cuda.is_bf16_supported()) else torch.float32
kw["model_kwargs"] = {"torch_dtype": dt}
return SentenceTransformer(arm["name"], device=str(device), **kw)
def text_features(cfg, texts, device, batch_size=64):
"""Cached frozen sentence embeddings (L2-normalised by the model's own head if it has one)."""
arm = text_arm(cfg)
d = rel(os.path.join(cfg["text_encoder"]["cache_dir"], "txt_" + safe(arm["name"])))
os.makedirs(d, exist_ok=True)
path = os.path.join(d, "all.pt")
cache = torch.load(path) if os.path.exists(path) else {}
key = lambda t: hashlib.md5((arm.get("prompt", "") + t).encode()).hexdigest()
need = sorted({t for t in texts if key(t) not in cache})
if need:
m = load_sentence_model(arm, device)
for s in range(0, len(need), 512):
chunk = need[s: s + 512]
e = m.encode([arm.get("prompt", "") + t for t in chunk], batch_size=batch_size,
convert_to_tensor=True, normalize_embeddings=True).float().cpu()
for t, v in zip(chunk, e):
cache[key(t)] = v
print(f" text feats {s + len(chunk)}/{len(need)}", flush=True)
torch.save(cache, path)
del m
return torch.stack([cache[key(t)] for t in texts])
class TrainableText(nn.Module):
"""Sentence-transformers model with only its top-N transformer layers trainable."""
def __init__(self, cfg, device):
super().__init__()
self.arm = text_arm(cfg)
self.st = load_sentence_model(self.arm, device)
n = cfg["text_encoder"]["unfreeze_last_n_layers"]
for p in self.st.parameters():
p.requires_grad = False
layers = None
for mod in self.st.modules(): # find the deepest ModuleList of transformer blocks
for name, child in mod.named_children():
if isinstance(child, nn.ModuleList) and name in ("layer", "layers") and len(child) > 1:
layers = child
if layers is None:
raise RuntimeError("could not locate transformer layers to unfreeze")
for blk in list(layers)[-n:]:
for p in blk.parameters():
p.requires_grad = True
self.max_len = cfg["data"]["max_text_len"]
self.device = device
def forward(self, texts):
texts = [self.arm.get("prompt", "") + t for t in texts]
feats = (self.st.preprocess if hasattr(self.st, "preprocess") else self.st.tokenize)(texts)
feats = {k: v[:, : self.max_len].to(self.device) if torch.is_tensor(v) and v.dim() == 2 else v for k, v in feats.items()}
return self.st(feats)["sentence_embedding"].float()
# ----------------------------------------------------------------------------- heads + loss
class Head(nn.Module):
def __init__(self, din, dout, hidden=0, dropout=0.0):
super().__init__()
self.net = nn.Linear(din, dout) if not hidden else nn.Sequential(
nn.Linear(din, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, dout))
def forward(self, x):
return F.normalize(self.net(x), dim=-1)
class AlignModel(nn.Module):
def __init__(self, d_img, d_txt, cfg):
super().__init__()
m = cfg["model"]
self.img = Head(d_img, m["proj_dim"], m["proj_hidden"], m["dropout"])
self.txt = Head(d_txt, m["proj_dim"], m["proj_hidden"], m["dropout"])
self.logit_scale = nn.Parameter(torch.tensor(float(m["init_logit_scale"])))
def clip_loss(zi, zt, img_ids, logit_scale, mask_same_image=True):
"""Symmetric InfoNCE. With multilingual captions several texts can share an image in one batch;
those off-diagonal same-image pairs are masked out of the negatives."""
s = logit_scale.clamp(max=4.6052).exp()
logits = s * zi @ zt.t()
if mask_same_image:
same = img_ids[:, None] == img_ids[None, :]
eye = torch.eye(len(img_ids), dtype=torch.bool, device=logits.device)
logits = logits.masked_fill(same & ~eye, float("-inf"))
y = torch.arange(len(img_ids), device=logits.device)
return 0.5 * (F.cross_entropy(logits, y) + F.cross_entropy(logits.t(), y))
# ----------------------------------------------------------------------------- metrics
@torch.no_grad()
def retrieval_recall(zi, zt, ks=(1, 5, 10)):
"""zi[i] and zt[i] are a matched image/caption pair (one caption per image within a language)."""
sim = zi @ zt.t()
n = sim.shape[0]
gt = torch.arange(n)
r_i2t = (sim.argsort(dim=1, descending=True) == gt[:, None]).float().argmax(dim=1)
r_t2i = (sim.t().argsort(dim=1, descending=True) == gt[:, None]).float().argmax(dim=1)
out = {"n": n}
for k in ks:
out[f"i2t_R@{k}"] = round(100 * (r_i2t < k).float().mean().item(), 2)
out[f"t2i_R@{k}"] = round(100 * (r_t2i < k).float().mean().item(), 2)
out["mean_R"] = round(float(np.mean([out[f"{d}_R@{k}"] for d in ("i2t", "t2i") for k in ks])), 2)
return out
def dump(obj, path):
os.makedirs(os.path.dirname(path), exist_ok=True)
json.dump(obj, open(path, "w"), indent=2)