File size: 20,417 Bytes
5e4ddb8 94228b9 d037570 5e4ddb8 d037570 5e4ddb8 94228b9 5e4ddb8 d037570 5e4ddb8 94228b9 d037570 5e4ddb8 94228b9 5e4ddb8 94228b9 5e4ddb8 d037570 5e4ddb8 8cf9b7a 5e4ddb8 8cf9b7a 5e4ddb8 8cf9b7a 5e4ddb8 8cf9b7a 5e4ddb8 94228b9 5e4ddb8 d037570 94228b9 d037570 94228b9 d037570 94228b9 d037570 94228b9 d037570 5e4ddb8 94228b9 5e4ddb8 94228b9 d037570 94228b9 d037570 5e4ddb8 d037570 5e4ddb8 d037570 94228b9 5e4ddb8 8cf9b7a d037570 94228b9 d037570 94228b9 d037570 94228b9 d037570 5e4ddb8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 | """Standalone inference for microblog-topic-classifier-v5.
The model reads one post (its text, plus up to two pictures) and returns probabilities for
broad 25 broad topics ("sports", "us_politics", ... "unclear")
paths 118 subtopics ("sports/baseball", ...); a broad topic with no subtopics has one entry named like itself
signals 11 scores between 0 and 1 (substance, news, promo, general_interest, sentiment, critical, ad,
engagement_bait, spam, self_promo, meme)
tone 6 tones (informative, humorous, personal, outraged, supportive, other)
Architecture: the post text goes through a fine-tuned Ettin-150M text encoder (mean pooled). Each picture goes through a
frozen SigLIP 2 so400m (512 px) image encoder; the pictures' embeddings are projected and averaged (one learned vector
stands in for "no picture"). Text vector, picture vector and their product go through a small network to the heads.
Only the Ettin weights, the projection and the heads are in this repository (model.safetensors); the SigLIP 2 image
encoder is downloaded from google/siglip2-so400m-patch16-512 the first time it is needed.
from topic_classifier import TopicClassifier, render_post
clf = TopicClassifier.from_pretrained("haileyok/microblog-topic-classifier-v5") # or a local folder
post = {"text": "Great win for the Braves tonight", "tags": [], "media_kinds": ["image"]}
out = clf.predict([{"text": render_post(post, with_pictures=True), "pictures": ["braves.jpg"]}])
print(out[0]["top_broad"], out[0]["broad"]["sports"])
Tested with torch 2.14.0 and transformers 5.17.0 (an NVIDIA GPU is strongly recommended for posts with pictures).
Probabilities for the broad topic and subtopic heads are temperature scaled (config.json: temperature_broad,
temperature_path), fitted on held-out validation posts so that the confidence is not over- or under-stated.
"""
import concurrent.futures as cf
import contextlib
import json
import os
import threading
import time
from pathlib import Path
import numpy as np
import torch
import torch.nn as nn
MAX_CHARS = 1800 # the post text is cut here, before tokenizing (as in training)
MAX_LINK_DESCRIPTION, MAX_QUOTE, MAX_IMAGE_TEXT = 300, 500, 300
VIDEO_KIND = {"image": "image", "video_thumb": "video"}
# --- turning a post into the text the model reads ----------------------------------------------------------------
def _one_line(s):
return " ".join(s.split())
def _truncate(s, n):
return s if len(s) <= n else s[: n - 1].rstrip() + "…"
def _lines(xs, n=0):
out = []
for s in xs or []:
s = _one_line(s)
if s:
out.append(_truncate(s, n) if n else s)
return out
def _media_summary(kinds):
order, count = [], {}
for k in kinds or []:
k = _one_line(k)
if not k:
continue
if k not in count:
order.append(k)
count[k] = count.get(k, 0) + 1
return ", ".join(f"1 {k}" if count[k] == 1 else f"{count[k]} {k}s" for k in order)
def render_post(post, with_pictures=False):
"""The text the model reads for a post.
`post` is a dict with any of these keys (all optional):
text the post text
tags list of hashtags
media_kinds list of "image" / "video", one per attachment
labels list of moderation labels on the post
media_alts list of alt texts
link_domain, link_title, link_description the link card, if any
quote_text the quoted post's text, if any
image_texts, image_text_sources text found in the pictures by OCR ("ocr") or a picture-description service
("luna"), one source per text
image_kinds "image" / "video_thumb" per picture (only used to fill in media_kinds, see below)
With `with_pictures=True` (you will pass the pictures themselves) any text found in the pictures is dropped, as
it was in training for picture posts, and an empty media_kinds is filled in from image_kinds.
"""
row = dict(post)
if with_pictures:
row["image_texts"], row["image_text_sources"] = [], []
if not row.get("media_kinds"):
row["media_kinds"] = [VIDEO_KIND[k] for k in row.get("image_kinds") or [] if k in VIDEO_KIND]
ocr, desc = [], []
for t, src in zip(row.get("image_texts") or [], row.get("image_text_sources") or []):
if src == "ocr":
ocr.append(t)
elif src == "luna":
desc.append(t)
labels = []
for l in row.get("labels") or []:
l = _one_line(l)
if l and not l.startswith("!") and l != "needs-review" and l not in labels:
labels.append(l)
link = {
"domain": _one_line(row.get("link_domain") or ""),
"title": _one_line(row.get("link_title") or ""),
"description": _truncate(_one_line(row.get("link_description") or ""), MAX_LINK_DESCRIPTION),
}
tags, seen = [], set()
for t in row.get("tags") or []:
t = _one_line(t).lstrip("#")
if t and t.lower() not in seen:
seen.add(t.lower())
tags.append(t)
text = (row.get("text") or "").strip()
alt, ocr, desc = _lines(row.get("media_alts")), _lines(ocr, MAX_IMAGE_TEXT), _lines(desc, MAX_IMAGE_TEXT)
media = _media_summary(row.get("media_kinds"))
quote = _truncate(_one_line(row.get("quote_text") or ""), MAX_QUOTE)
out = []
if text:
out.append(text)
if tags:
out.append("[tags] " + " ".join("#" + t for t in tags))
if media:
out.append("[media] " + media)
if labels:
out.append("[labels] " + ", ".join(labels))
if alt:
out.append("[alt] " + " | ".join(alt))
if ocr:
out.append("[image text] " + " | ".join(ocr))
if desc:
out.append("[image description] " + " | ".join(desc))
if any(link.values()):
out.append("[link] " + " | ".join(p for p in (link["domain"], link["title"], link["description"]) if p))
if quote:
out.append("[quote] " + quote)
return "\n".join(out)
# --- the model ---------------------------------------------------------------------------------------------------
class FusionModel(nn.Module):
def __init__(self, text_config, img_dim, n_broad, n_paths, n_signals, n_tones):
super().__init__()
from transformers import AutoModel
self.text = AutoModel.from_config(text_config)
h = self.text.config.hidden_size
self.img = nn.Sequential(nn.LayerNorm(img_dim), nn.Linear(img_dim, h), nn.GELU())
self.no_img = nn.Parameter(torch.zeros(h))
self.fuse = nn.Sequential(nn.LayerNorm(3 * h), nn.Linear(3 * h, h), nn.GELU(), nn.Dropout(0.1))
self.broad, self.path = nn.Linear(h, n_broad), nn.Linear(h, n_paths)
self.signals, self.tone = nn.Linear(h, n_signals), nn.Linear(h, n_tones)
def forward(self, input_ids, attention_mask, img, img_mask):
hs = self.text(input_ids=input_ids, attention_mask=attention_mask).last_hidden_state
m = attention_mask.unsqueeze(-1).to(hs.dtype)
t = (hs * m).sum(1) / m.sum(1).clamp(min=1)
v = self.img(img.to(t.dtype)) # [batch, pictures, hidden]
w = img_mask.unsqueeze(-1).to(v.dtype)
have = w.sum(1)
pooled = (v * w).sum(1) / have.clamp(min=1)
v = torch.where(have > 0, pooled, self.no_img.to(pooled.dtype).expand_as(pooled))
z = self.fuse(torch.cat([t, v, t * v], -1))
return self.broad(z), self.path(z), self.signals(z), self.tone(z)
class TopicClassifier:
def __init__(self, folder, device=None, image_encoder=None, decode_threads=None, prefetch=True):
"""decode_threads: threads that decode pictures (default the TOPIC_DECODE_THREADS environment variable, else 8;
1 decodes on the calling thread). prefetch: decode the next batch while the current one runs on the GPU."""
from safetensors.torch import load_file
from transformers import AutoConfig, AutoModel, AutoProcessor, AutoTokenizer
folder = Path(folder)
self.cfg = json.load(open(folder / "config.json"))
self.device = torch.device(device or ("cuda" if torch.cuda.is_available() else "cpu"))
self.tok = AutoTokenizer.from_pretrained(folder / "tokenizer")
self.model = FusionModel(AutoConfig.from_pretrained(folder / "text_encoder"), self.cfg["image_features"]["dim"],
len(self.cfg["broad"]), len(self.cfg["paths"]), len(self.cfg["signals"]), len(self.cfg["tones"]))
self.model.load_state_dict(load_file(folder / "model.safetensors"), strict=True)
self.model.to(self.device).eval()
self._image_encoder_id = image_encoder or self.cfg["image_features"]["model"]
self._image_model = self._image_processor = None
self._image_lock = threading.Lock()
self._AutoModel, self._AutoProcessor = AutoModel, AutoProcessor
self.t_broad, self.t_path = self.cfg["temperature_broad"], self.cfg["temperature_path"]
n = decode_threads if decode_threads is not None else int(os.environ.get("TOPIC_DECODE_THREADS", "8"))
self._decode_pool = cf.ThreadPoolExecutor(n, thread_name_prefix="decode") if n > 1 else None
self._prefetch = prefetch
# Optional timing hook, called as stage_timer(name, seconds, count=1) from whichever thread ran the stage
# (the service sets it to log where the time goes). None costs nothing.
self.stage_timer = None
@contextlib.contextmanager
def _stage(self, name):
if self.stage_timer is None:
yield
return
t0 = time.perf_counter()
try:
yield
finally:
self.stage_timer(name, time.perf_counter() - t0)
def _count(self, name, n):
if self.stage_timer is not None:
self.stage_timer(name, 0.0, n)
@classmethod
def from_pretrained(cls, path_or_repo, device=None, image_encoder=None, **kwargs):
"""A local folder with the repository's files, or a Hugging Face repository id."""
folder = path_or_repo
if not os.path.isdir(folder):
from huggingface_hub import snapshot_download
folder = snapshot_download(path_or_repo)
return cls(folder, device, image_encoder, **kwargs)
# -- pictures
def _load_image_encoder(self):
if self._image_model is not None:
return
with self._image_lock: # several threads may prepare batches at once
if self._image_model is None:
dtype = torch.bfloat16 if self.device.type == "cuda" else torch.float32
m = self._AutoModel.from_pretrained(self._image_encoder_id, dtype=dtype)
if hasattr(m, "text_model"):
del m.text_model # only the picture tower is used
processor = self._AutoProcessor.from_pretrained(self._image_encoder_id)
self._image_processor = processor
self._image_model = m.to(self.device).eval() # set last: other threads treat it as "loaded"
def load_image_encoder(self):
"""Load the picture encoder now (it is otherwise loaded when the first picture arrives)."""
self._load_image_encoder()
@staticmethod
def _open(p):
"""A PIL RGB image from encoded bytes, a file path or a PIL image; None when it cannot be read."""
import io
from PIL import Image
try:
if isinstance(p, (bytes, bytearray, memoryview)):
with Image.open(io.BytesIO(p)) as im:
return im.convert("RGB")
if isinstance(p, (str, os.PathLike)):
with Image.open(p) as im:
return im.convert("RGB")
return p.convert("RGB")
except Exception: # noqa: BLE001 broken, truncated or hostile files must not fail a whole batch
return None
def embed_pictures(self, ims):
"""One embedding per PIL RGB image, as float16 (the encoder's pooled output, as stored in training)."""
self._load_image_encoder()
return self._embed_pixels(self._image_processor(images=ims, return_tensors="pt")["pixel_values"])
@torch.no_grad()
def _embed_pixels(self, pixel_values):
"""The picture encoder on the GPU, for pictures the image processor has already resized and normalised."""
px = pixel_values.to(self.device, self._image_model.dtype)
out = self._image_model.get_image_features(pixel_values=px)
emb = out if torch.is_tensor(out) else out.pooler_output
return emb.float().cpu().numpy().astype(np.float16)
# -- predictions
def _pixels(self, p):
"""One picture decoded, resized and normalised by the image processor: a float32 tensor [3, H, W], or None
when the file cannot be read. Run on the decode threads; the result is exactly what the processor gives for
the same picture inside a batch."""
im = self._open(p)
if im is None:
return None
with self._stage("prep.resize"): # summed over threads
return self._image_processor(images=[im], return_tensors="pt")["pixel_values"][0]
def prepare_batch(self, items):
"""The CPU part of a batch: tokenize the texts and decode, resize and normalise the pictures (one picture per
task on the decode threads). Thread safe; it can run for the next batches while the current one is on the GPU."""
max_images = self.cfg["max_images"]
t_prep = time.perf_counter()
with self._stage("prep.tokenize"):
enc = self.tok([(it["text"] or "")[:MAX_CHARS] for it in items], truncation=True, max_length=self.cfg["max_len"])["input_ids"]
refs, sources = [], []
for b, it in enumerate(items):
for p in list(it.get("pictures") or [])[:max_images]:
refs.append(b)
sources.append(p)
px, ok = None, []
if sources:
self._load_image_encoder()
with self._stage("prep.pictures"): # wall time of decoding and resizing the batch's pictures
if self._decode_pool is not None and len(sources) > 1:
tensors = list(self._decode_pool.map(self._pixels, sources))
else:
tensors = [self._pixels(p) for p in sources]
ok = [t is not None for t in tensors]
readable = [t for t in tensors if t is not None]
if readable:
px = torch.stack(readable)
if self.stage_timer is not None:
self.stage_timer("prep", time.perf_counter() - t_prep)
self._count("pictures", sum(ok))
return enc, refs, ok, px
def _finish(self, n, prepared):
"""The GPU part: embed the decoded pictures and build the model's input tensors."""
max_images, dim = self.cfg["max_images"], self.cfg["image_features"]["dim"]
enc, refs, ok, px = prepared
L = max(len(x) for x in enc)
ids = torch.full((n, L), self.tok.pad_token_id, dtype=torch.long)
att = torch.zeros((n, L), dtype=torch.long)
for b, x in enumerate(enc):
ids[b, : len(x)] = torch.tensor(x)
att[b, : len(x)] = 1
img = np.zeros((n, max_images, dim), np.float16)
have = np.zeros((n, max_images), bool)
where, slot = [], [0] * n
for b, readable in zip(refs, ok):
if not readable: # a picture that cannot be read is skipped, as in training
continue
where.append((b, slot[b]))
slot[b] += 1
if px is not None:
with self._stage("gpu.embed"): # copy to the GPU, the picture encoder, and the copy back (waits for the GPU)
emb = self._embed_pixels(px)
for (b, k), e in zip(where, emb):
img[b, k] = e
have[b, k] = True
d = self.device
with self._stage("gpu.upload"):
out = (ids.to(d), att.to(d), torch.from_numpy(img).to(d), torch.from_numpy(have).to(d))
return out, have.sum(1)
@torch.no_grad()
def run_batch(self, n, prepared):
"""The GPU part for one batch of `n` posts that prepare_batch has prepared: ([broad, paths, signals, tone]
probabilities as numpy arrays, pictures_used [n]). Call it from one thread at a time (the GPU)."""
with self._stage("finish"):
inputs, n_used = self._finish(n, prepared)
with self._stage("gpu.forward"): # text model and heads, up to the copy of the probabilities back
with torch.autocast(self.device.type, dtype=torch.bfloat16, enabled=self.device.type == "cuda"):
lb, lp, ls, lt = self.model(*inputs)
probs = (torch.softmax(lb.float() / self.t_broad, -1), torch.softmax(lp.float() / self.t_path, -1),
torch.sigmoid(ls.float()), torch.softmax(lt.float(), -1))
arrays = [p.cpu().numpy() for p in probs]
self._count("batches", 1)
self._count("batch.posts", n)
return arrays, n_used
@staticmethod
def plan_batches(items, batch_size=32):
"""Splits the items into batches of similar text length: (order, batches), where batches holds the items in
that order, `batch_size` at a time."""
order = np.argsort([len(it["text"] or "") for it in items], kind="stable") # similar lengths together
return order, [[items[i] for i in order[s:s + batch_size]] for s in range(0, len(items), batch_size)]
@staticmethod
def assemble(order, parts, used):
"""The predict_arrays result from per-batch results (parts[k] = run_batch's arrays, used[k] = pictures_used),
in batch order, put back in the input order."""
inv = np.empty(len(order), int)
inv[order] = np.arange(len(order))
out = {name: np.concatenate([p[j] for p in parts])[inv] for j, name in enumerate(("broad", "paths", "signals", "tone"))}
out["pictures_used"] = np.concatenate(used)[inv]
return out
@torch.no_grad()
def predict_arrays(self, items, batch_size=32):
"""Arrays in the input order: broad [n, 25], paths [n, 118], signals [n, 11], tone [n, 6] (all
probabilities) and pictures_used [n] (how many of each post's pictures could be read and were used)."""
order, batches = self.plan_batches(items, batch_size)
parts, used = [], []
ahead = cf.ThreadPoolExecutor(1, thread_name_prefix="prefetch") if self._prefetch and len(batches) > 1 else None
try:
nxt = ahead.submit(self.prepare_batch, batches[0]) if ahead else None
for k, batch in enumerate(batches):
with self._stage("wait_prep.first" if k == 0 else "wait_prep"): # the GPU is idle while this waits
prepared = nxt.result() if ahead else self.prepare_batch(batch)
nxt = ahead.submit(self.prepare_batch, batches[k + 1]) if ahead and k + 1 < len(batches) else None
arrays, n_used = self.run_batch(len(batch), prepared)
parts.append(arrays)
used.append(n_used)
finally:
if ahead:
ahead.shutdown(wait=True)
return self.assemble(order, parts, used)
def predict(self, items, batch_size=32, top_k=None):
"""items: list of {"text": str, "pictures": [PIL image or file path, ...]} ("pictures" optional).
Returns one dict per item: broad, paths, signals, tone (name -> probability, broad/paths sorted high to low,
cut to top_k when given) and top_broad, top_path.
"""
a = self.predict_arrays(items, batch_size)
out = []
for i in range(len(items)):
r = {}
for key, names in (("broad", self.cfg["broad"]), ("paths", self.cfg["paths"])):
pairs = sorted(zip(names, a[key][i].tolist()), key=lambda kv: -kv[1])
r[key] = dict(pairs[:top_k] if top_k else pairs)
r["signals"] = dict(zip(self.cfg["signals"], a["signals"][i].tolist()))
r["tone"] = dict(zip(self.cfg["tones"], a["tone"][i].tolist()))
r["top_broad"], r["top_path"] = next(iter(r["broad"])), next(iter(r["paths"]))
out.append(r)
return out
|