Download nbmm/common.py from Modularcomputing/Native-Bird2: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/Modularcomputing/Native-Bird2/resolve/main/nbmm/common.py
- Command line
-
hf download hf://Modularcomputing/Native-Bird2/nbmm/common.py
-
curl -L -o common.py https://huggingface.co/Modularcomputing/Native-Bird2/resolve/main/nbmm/common.py
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) | |
| 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 | |
| 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) | |