Tool call · Input
/app/solution/train_two_tower.py
#!/usr/bin/env python3
"""Train a CPU PyTorch two-tower ranker and write validation + test rankings."""
from __future__ import annotations
import json
import math
import os
import sys
import time
from dataclasses import dataclass
from pathlib import Path
import numpy as np
import pandas as pd
import torch
import torch.nn.functional as F
from torch.optim import AdamW
from torch.optim.lr_scheduler import CosineAnnealingLR
os.environ.setdefault("OMP_NUM_THREADS", "8")
os.environ.setdefault("MKL_NUM_THREADS", "8")
torch.set_num_threads(8)
from model import TwoTower
sys.path.insert(0, "/task")
from ranking_evaluation import CandidateSet, score_rankings
DATA = Path("/task/data")
SOLUTION = Path("/app/solution")
SOLUTION.mkdir(parents=True, exist_ok=True)
TRAIN_CUTOFF = 24_786_800
DAY = 86_400
WINDOW = 7 * DAY
N_HASH = 8192
N_STATS = 8
D_MODEL = 64
RECENT_DAYS = 21.0
L_RECENT = 40
L_LONG = 128
L_LIKE = 64
L_DISLIKE = 32
L_OOV = 40
MIN_HIST = 8
LISTEN_POS = 50
LISTEN_STRONG = 80
N_NEG = 512
BATCH_SIZE = 256
MAX_EXAMPLES_VAL = 360_000
MAX_EXAMPLES_REFIT = 480_000
SEED = 42
ITEM_LOOKUP_SIZE = 10_000_000
def log(msg: str) -> None:
print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)
@dataclass
class UserRec:
all_item: np.ndarray
all_ts: np.ndarray
org_item: np.ndarray
org_ts: np.ndarray
org_like: np.ndarray
pos_idx: np.ndarray
pos_ts: np.ndarray
pos_org: np.ndarray
pos_ratio: np.ndarray
strong_idx: np.ndarray
strong_ts: np.ndarray
oov_idx: np.ndarray
oov_ts: np.ndarray
like_idx: np.ndarray
like_ts: np.ndarray
dislike_idx: np.ndarray
dislike_ts: np.ndarray
def _sort_uid_ts(df: pd.DataFrame) -> pd.DataFrame:
return df.sort_values(["uid", "timestamp"], kind="mergesort")
def _partition(uids: np.ndarray, **cols: np.ndarray) -> dict[int, dict[str, np.ndarray]]:
n = int(uids.shape[0])
if n == 0:
return {}
breaks = np.flatnonzero(uids[1:] != uids[:-1]) + 1
starts = np.concatenate(([0], breaks))
ends = np.concatenate((breaks, [n]))
out: dict[int, dict[str, np.ndarray]] = {}
for a, b in zip(starts.tolist(), ends.tolist()):
uid = int(uids[a])
out[uid] = {k: v[a:b] for k, v in cols.items()}
return out
def _empty_i64() -> np.ndarray:
return np.zeros(0, dtype=np.int64)
def _empty_i8() -> np.ndarray:
return np.zeros(0, dtype=np.int8)
def _pad_last(ids: np.ndarray, ages: np.ndarray, length: int) -> tuple[np.ndarray, np.ndarray]:
out_ids = np.zeros(length, dtype=np.int64)
out_age = np.zeros(length, dtype=np.float32)
n = int(ids.shape[0])
if n <= 0:
return out_ids, out_age
sl = min(n, length)
out_ids[-sl:] = ids[-sl:]
out_age[-sl:] = ages[-sl:].astype(np.float32, copy=False)
return out_ids, out_age
def _age_days(ts: np.ndarray, cutoff: int) -> np.ndarray:
return (cutoff - ts).astype(np.float64) / DAY
def _stats(rec: UserRec, cutoff: int) -> np.ndarray:
i = int(np.searchsorted(rec.pos_ts, cutoff))
j = int(np.searchsorted(rec.like_ts, cutoff))
k = int(np.searchsorted(rec.dislike_ts, cutoff))
pos_ts = rec.pos_ts[:i]
n_pos = i
n_like = j
n_dis = k
if n_pos:
unique = np.unique(rec.pos_idx[:i]).size
org_frac = float(rec.pos_org[:i].mean())
mean_ratio = float(rec.pos_ratio[:i].mean()) / 100.0
first_age = float(cutoff - int(pos_ts[0])) / DAY
last_age = float(cutoff - int(pos_ts[-1])) / DAY
else:
unique = 0
org_frac = 0.0
mean_ratio = 0.0
first_age = 0.0
last_age = 0.0
return np.array(
[
math.log1p(n_pos) / 10.0,
math.log1p(n_like) / 5.0,
math.log1p(n_dis) / 5.0,
math.log1p(unique) / 10.0,
org_frac,
mean_ratio,
math.log1p(max(first_age, 0.0)) / 6.0,
math.log1p(max(last_age, 0.0)) / 4.0,
],
dtype=np.float32,
)
def load_catalog() -> tuple[np.ndarray, np.ndarray, np.ndarray]:
cat = pd.read_parquet(DATA / "candidate_catalog.parquet")
item_ids = cat.item_id.to_numpy(dtype=np.int64)
pos = cat.positive_listens.to_numpy(dtype=np.float32)
org = cat.organic_positive_listens.to_numpy(dtype=np.float32)
rank = cat.popularity_rank.to_numpy(dtype=np.float32)
feat = np.stack(
[
np.log1p(pos) / 10.0,
np.log1p(org) / 10.0,
np.divide(org, pos, out=np.zeros_like(org), where=pos > 0),
np.log(rank) / 10.0,
],
axis=1,
).astype(np.float32)
lookup = np.full(ITEM_LOOKUP_SIZE, -1, dtype=np.int32)
valid = (item_ids >= 0) & (item_ids < ITEM_LOOKUP_SIZE)
lookup[item_ids[valid]] = np.arange(len(item_ids), dtype=np.int32)[valid]
return item_ids, feat, lookup
def _hash_oov(raw: np.ndarray, n_items: int) -> np.ndarray:
return n_items + 1 + (raw.astype(np.int64) * np.int64(2654435761) % N_HASH)
def _map_catalog(raw: np.ndarray, lookup: np.ndarray) -> np.ndarray:
mapped = np.full(raw.shape[0], -1, dtype=np.int32)
in_range = (raw >= 0) & (raw < lookup.shape[0])
mapped[in_range] = lookup[raw[in_range]]
return mapped
def _read_events(splits: list[str], family: str, columns: list[str]) -> pd.DataFrame:
frames = [pd.read_parquet(DATA / f"{split}_{family}.parquet", columns=columns) for split in splits]
return pd.concat(frames, ignore_index=True) if len(frames) > 1 else frames[0]
def build_user_recs(splits: list[str], lookup: np.ndarray, n_items: int) -> dict[int, UserRec]:
log(f"loading events for splits={splits}")
listens = _read_events(
splits, "listens", ["uid", "timestamp", "item_id", "is_organic", "played_ratio_pct"]
)
likes = _read_events(splits, "likes", ["uid", "timestamp", "item_id", "is_organic"])
dislikes = _read_events(splits, "dislikes", ["uid", "timestamp", "item_id"])
unlikes = _read_events(splits, "unlikes", ["uid", "timestamp", "item_id"])
undislikes = _read_events(splits, "undislikes", ["uid", "timestamp", "item_id"])
def arrays(df: pd.DataFrame) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
return (
df.uid.to_numpy(dtype=np.int64),
df.timestamp.to_numpy(dtype=np.int64),
df.item_id.to_numpy(dtype=np.int64),
)
uid_l, ts_l, item_l = arrays(listens)
org_l = listens.is_organic.to_numpy(dtype=np.int8)
ratio_l = listens.played_ratio_pct.to_numpy(dtype=np.int16)
del listens
cat_idx_l = _map_catalog(item_l, lookup)
is_cat_l = cat_idx_l >= 0
# All catalog interactions (listens of any completion + explicit actions).
seen_uid = [uid_l[is_cat_l], ts_l[is_cat_l], cat_idx_l[is_cat_l].astype(np.int64)]
for df in (likes, dislikes, unlikes, undislikes):
u, t, it = arrays(df)
mapped = _map_catalog(it, lookup)
m = mapped >= 0
seen_uid[0] = np.concatenate([seen_uid[0], u[m]])
seen_uid[1] = np.concatenate([seen_uid[1], t[m]])
seen_uid[2] = np.concatenate([seen_uid[2], mapped[m].astype(np.int64)])
seen_order = np.lexsort((seen_uid[1], seen_uid[0]))
seen_parts = _partition(
seen_uid[0][seen_order],
item=seen_uid[2][seen_order],
ts=seen_uid[1][seen_order],
)
del seen_uid, seen_order
pos_m = ratio_l >= LISTEN_POS
pos_cat = pos_m & is_cat_l
pos_oov = pos_m & ~is_cat_l
pos_order = np.lexsort((ts_l[pos_cat], uid_l[pos_cat]))
pos_parts = _partition(
uid_l[pos_cat][pos_order],
idx=(cat_idx_l[pos_cat].astype(np.int64)[pos_order] + 1),
ts=ts_l[pos_cat][pos_order],
org=org_l[pos_cat][pos_order],
ratio=ratio_l[pos_cat][pos_order],
)
oov_order = np.lexsort((ts_l[pos_oov], uid_l[pos_oov]))
oov_parts = _partition(
uid_l[pos_oov][oov_order],
idx=_hash_oov(item_l[pos_oov][oov_order], n_items),
ts=ts_l[pos_oov][oov_order],
)
strong_m = is_cat_l & (org_l == 1) & (ratio_l >= LISTEN_STRONG)
strong_order = np.lexsort((ts_l[strong_m], uid_l[strong_m]))
strong_parts = _partition(
uid_l[strong_m][strong_order],
idx=(cat_idx_l[strong_m].astype(np.int64)[strong_order] + 1),
ts=ts_l[strong_m][strong_order],
item=cat_idx_l[strong_m].astype(np.int64)[strong_order],
)
like_u, like_ts, like_item = arrays(likes)
like_org = likes.is_organic.to_numpy(dtype=np.int8)
like_map = _map_catalog(like_item, lookup)
like_hist_idx = np.where(like_map >= 0, like_map.astype(np.int64) + 1, _hash_oov(like_item, n_items))
like_order = np.lexsort((like_ts, like_u))
like_parts = _partition(
like_u[like_order],
idx=like_hist_idx[like_order],
ts=like_ts[like_order],
item=like_map[like_order].astype(np.int64),
org=like_org[like_order],
)
org_like_m = (like_org == 1) & (like_map >= 0)
org_like_order = np.lexsort((like_ts[org_like_m], like_u[org_like_m]))
org_like_parts = _partition(
like_u[org_like_m][org_like_order],
item=like_map[org_like_m].astype(np.int64)[org_like_order],
ts=like_ts[org_like_m][org_like_order],
)
dis_u, dis_ts, dis_item = arrays(dislikes)
dis_map = _map_catalog(dis_item, lookup)
dis_hist = np.where(dis_map >= 0, dis_map.astype(np.int64) + 1, _hash_oov(dis_item, n_items))
dis_order = np.lexsort((dis_ts, dis_u))
dis_parts = _partition(
dis_u[dis_order],
idx=dis_hist[dis_order],
ts=dis_ts[dis_order],
)
del uid_l, ts_l, item_l, org_l, ratio_l, cat_idx_l, likes, dislikes, unlikes, undislikes
uids = set(seen_parts) | set(pos_parts) | set(like_parts) | set(strong_parts)
recs: dict[int, UserRec] = {}
z64 = _empty_i64()
z8 = _empty_i8()
for uid in uids:
seen = seen_parts.get(uid, {})
pos = pos_parts.get(uid, {})
oov = oov_parts.get(uid, {})
strong = strong_parts.get(uid, {})
like = like_parts.get(uid, {})
dis = dis_parts.get(uid, {})
ol = org_like_parts.get(uid, {})
strong_item = strong.get("item", z64)
strong_ts = strong.get("ts", z64)
ol_item = ol.get("item", z64)
ol_ts = ol.get("ts", z64)
if strong_item.size and ol_item.size:
org_item = np.concatenate([strong_item, ol_item])
org_ts = np.concatenate([strong_ts, ol_ts])
org_like_flag = np.concatenate(
[np.zeros(strong_item.size, dtype=np.int8), np.ones(ol_item.size, dtype=np.int8)]
)
order = np.argsort(org_ts, kind="mergesort")
org_item, org_ts, org_like_flag = org_item[order], org_ts[order], org_like_flag[order]
elif ol_item.size:
org_item, org_ts = ol_item, ol_ts
org_like_flag = np.ones(ol_item.size, dtype=np.int8)
else:
org_item, org_ts = strong_item, strong_ts
org_like_flag = np.zeros(strong_item.size, dtype=np.int8)
recs[uid] = UserRec(
all_item=seen.get("item", z64),
all_ts=seen.get("ts", z64),
org_item=org_item,
org_ts=org_ts,
org_like=org_like_flag,
pos_idx=pos.get("idx", z64),
pos_ts=pos.get("ts", z64),
pos_org=pos.get("org", z8),
pos_ratio=pos.get("ratio", z8),
strong_idx=strong.get("idx", z64),
strong_ts=strong_ts,
oov_idx=oov.get("idx", z64),
oov_ts=oov.get("ts", z64),
like_idx=like.get("idx", z64),
like_ts=like.get("ts", z64),
dislike_idx=dis.get("idx", z64),
dislike_ts=dis.get("ts", z64),
)
log(f"built user records for {len(recs)} users")
return recs
def history_channels(
rec: UserRec,
cutoff: int,
like_override: tuple[np.ndarray, np.ndarray] | None = None,
dislike_override: tuple[np.ndarray, np.ndarray] | None = None,
) -> dict[str, np.ndarray]:
def take(ids: np.ndarray, ts: np.ndarray, length: int, recent_only: bool = False) -> tuple[np.ndarray, np.ndarray]:
end = int(np.searchsorted(ts, cutoff))
ids, ts = ids[:end], ts[:end]
if ids.size == 0:
return _pad_last(ids, np.zeros(0, np.float32), length)
age = _age_days(ts, cutoff)
if recent_only:
keep = age <= RECENT_DAYS
ids, age = ids[keep], age[keep]
return _pad_last(ids, age, length)
recent_ids, recent_age = take(rec.strong_idx, rec.strong_ts, L_RECENT, recent_only=True)
if int((recent_ids > 0).sum()) < 4:
recent_ids, recent_age = take(rec.pos_idx, rec.pos_ts, L_RECENT, recent_only=True)
long_ids, long_age = take(rec.pos_idx, rec.pos_ts, L_LONG, recent_only=False)
oov_ids, oov_age = take(rec.oov_idx, rec.oov_ts, L_OOV, recent_only=False)
if like_override is None:
like_ids, like_age = take(rec.like_idx, rec.like_ts, L_LIKE)
else:
like_ids, like_age = _pad_last(like_override[0], like_override[1], L_LIKE)
if dislike_override is None:
dis_ids, dis_age = take(rec.dislike_idx, rec.dislike_ts, L_DISLIKE)
else:
dis_ids, dis_age = _pad_last(dislike_override[0], dislike_override[1], L_DISLIKE)
return {
"recent_ids": recent_ids,
"recent_age": recent_age,
"long_ids": long_ids,
"long_age": long_age,
"like_ids": like_ids,
"like_age": like_age,
"dislike_ids": dis_ids,
"dislike_age": dis_age,
"oov_ids": oov_ids,
"oov_age": oov_age,
"stats": _stats(rec, cutoff),
}
def _seen_before(rec: UserRec, cutoff: int) -> np.ndarray:
end = int(np.searchsorted(rec.all_ts, cutoff))
if end == 0:
return np.zeros(0, dtype=np.int64)
return np.unique(rec.all_item[:end])
def collect_examples(
recs: dict[int, UserRec],
snapshots: list[int],
max_examples: int,
rng: np.random.Generator,
) -> dict[str, np.ndarray]:
rows: list[dict[str, np.ndarray | float | int]] = []
for snap in snapshots:
win_end = snap + WINDOW
for uid, rec in recs.items():
if rec.pos_ts.size == 0:
continue
n_hist = int(np.searchsorted(rec.pos_ts, snap))
if n_hist < MIN_HIST:
continue
seen = _seen_before(rec, snap)
seen_set = set(seen.tolist()) if seen.size else set()
lo = int(np.searchsorted(rec.org_ts, snap))
hi = int(np.searchsorted(rec.org_ts, win_end))
if hi <= lo:
continue
hist = history_channels(rec, snap)
used: set[int] = set()
for item, ts, is_like in zip(rec.org_item[lo:hi], rec.org_ts[lo:hi], rec.org_like[lo:hi]):
item = int(item)
if item in seen_set or item in used:
continue
used.add(item)
weight = 3.0 if int(is_like) == 1 else 1.0
row = dict(hist)
row["target"] = item
row["weight"] = weight
rows.append(row)
seen_set.add(item)
# Sequential next-discovery examples from later history.
for uid, rec in recs.items():
if rec.org_ts.size < MIN_HIST + 2:
continue
first_ts: dict[int, int] = {}
for item, ts in zip(rec.all_item, rec.all_ts):
item = int(item)
if item not in first_ts:
first_ts[item] = int(ts)
discoveries: list[tuple[int, int, int]] = []
seen_d: set[int] = set()
for item, ts, is_like in zip(rec.org_item, rec.org_ts, rec.org_like):
item = int(item)
if item in seen_d:
continue
ft = first_ts.get(item, int(ts))
if int(ts) - ft > WINDOW:
continue
seen_d.add(item)
discoveries.append((ft, item, int(is_like)))
if len(discoveries) < MIN_HIST + 2:
continue
start = max(MIN_HIST, int(0.55 * len(discoveries)))
for i in range(start, len(discoveries)):
if (i < len(discoveries) - 6) and (i % 4 != 0):
continue
snap, item, is_like = discoveries[i]
n_hist = int(np.searchsorted(rec.pos_ts, snap))
if n_hist < MIN_HIST:
continue
hist = history_channels(rec, snap)
row = dict(hist)
row["target"] = int(item)
row["weight"] = 3.0 if is_like else 1.0
rows.append(row)
if not rows:
raise RuntimeError("no training examples constructed")
log(f"raw example count {len(rows)}")
if len(rows) > max_examples:
weights = np.array([float(r["weight"]) for r in rows], dtype=np.float64)
p = weights / weights.sum()
pick = rng.choice(len(rows), size=max_examples, replace=False, p=p)
rows = [rows[i] for i in pick.tolist()]
log(f"subsampled to {len(rows)} examples")
keys = [
"recent_ids",
"recent_age",
"long_ids",
"long_age",
"like_ids",
"like_age",
"dislike_ids",
"dislike_age",
"oov_ids",
"oov_age",
"stats",
"target",
"weight",
]
out: dict[str, np.ndarray] = {}
for key in keys:
if key == "target":
out[key] = np.asarray([int(r[key]) for r in rows], dtype=np.int64)
elif key == "weight":
out[key] = np.asarray([float(r[key]) for r in rows], dtype=np.float32)
else:
out[key] = np.stack([r[key] for r in rows], axis=0)
log(
f"example tensor shapes: "
+ ", ".join(f"{k}{tuple(v.shape)}" for k, v in out.items())
)
return out
def preference_overrides(
path: Path,
lookup: np.ndarray,
n_items: int,
recs: dict[int, UserRec],
cutoff: int,
) -> dict[int, dict[str, tuple[np.ndarray, np.ndarray]]]:
pref = pd.read_parquet(path)
out: dict[int, dict[str, tuple[np.ndarray, np.ndarray]]] = {}
for uid, grp in pref.groupby("uid"):
uid = int(uid)
rec = recs.get(uid)
last_like: dict[int, int] = {}
last_dis: dict[int, int] = {}
if rec is not None:
for idx, ts in zip(rec.like_idx, rec.like_ts):
last_like[int(idx)] = int(ts)
for idx, ts in zip(rec.dislike_idx, rec.dislike_ts):
last_dis[int(idx)] = int(ts)
likes_idx: list[int] = []
likes_age: list[float] = []
dis_idx: list[int] = []
dis_age: list[float] = []
for item, state in zip(grp.item_id.to_numpy(np.int64), grp.state.to_numpy()):
mapped = int(lookup[item]) if 0 <= item < lookup.shape[0] else -1
if mapped < 0:
hist_idx = int(_hash_oov(np.array([item]), n_items)[0])
else:
hist_idx = mapped + 1
if state == "liked":
ts = last_like.get(hist_idx, cutoff - 14 * DAY)
likes_idx.append(hist_idx)
likes_age.append(max(0.0, (cutoff - ts) / DAY))
elif state == "disliked":
ts = last_dis.get(hist_idx, cutoff - 14 * DAY)
dis_idx.append(hist_idx)
dis_age.append(max(0.0, (cutoff - ts) / DAY))
out[uid] = {
"like": (np.asarray(likes_idx, dtype=np.int64), np.asarray(likes_age, dtype=np.float32)),
"dislike": (np.asarray(dis_idx, dtype=np.int64), np.asarray(dis_age, dtype=np.float32)),
}
return out
def encode_users(
model: TwoTower,
recs: dict[int, UserRec],
uids: np.ndarray,
cutoff: int,
overrides: dict[int, dict[str, tuple[np.ndarray, np.ndarray]]] | None,
batch_size: int = 256,
) -> np.ndarray:
model.eval()
vectors = np.zeros((len(uids), model.d_model), dtype=np.float32)
with torch.no_grad():
for start in range(0, len(uids), batch_size):
chunk = uids[start : start + batch_size]
bag: dict[str, list[np.ndarray]] = {
"recent_ids": [],
"recent_age": [],
"long_ids": [],
"long_age": [],
"like_ids": [],
"like_age": [],
"dislike_ids": [],
"dislike_age": [],
"oov_ids": [],
"oov_age": [],
"stats": [],
}
for uid in chunk:
uid = int(uid)
rec = recs.get(uid)
if rec is None:
rec = UserRec(
all_item=_empty_i64(),
all_ts=_empty_i64(),
org_item=_empty_i64(),
org_ts=_empty_i64(),
org_like=_empty_i8(),
pos_idx=_empty_i64(),
pos_ts=_empty_i64(),
pos_org=_empty_i8(),
pos_ratio=_empty_i8(),
strong_idx=_empty_i64(),
strong_ts=_empty_i64(),
oov_idx=_empty_i64(),
oov_ts=_empty_i64(),
like_idx=_empty_i64(),
like_ts=_empty_i64(),
dislike_idx=_empty_i64(),
dislike_ts=_empty_i64(),
)
like_ov = dis_ov = None
if overrides is not None and uid in overrides:
like_ov = overrides[uid]["like"]
dis_ov = overrides[uid]["dislike"]
hist = history_channels(rec, cutoff, like_ov, dis_ov)
for k, v in hist.items():
bag[k].append(v)
batch = {
k: torch.from_numpy(np.stack(v, axis=0))
for k, v in bag.items()
}
vec = model.user_vectors(batch)
vectors[start : start + len(chunk)] = vec.cpu().numpy()
return vectors
def rank_users(
user_vecs: np.ndarray,
item_vecs: np.ndarray,
elig: np.ndarray,
item_ids: np.ndarray,
uids: np.ndarray,
top_n: int = 100,
) -> pd.DataFrame:
scores = user_vecs @ item_vecs.T
scores = np.where(elig, scores, -1e9).astype(np.float32, copy=False)
idx = np.argpartition(-scores, kth=top_n - 1, axis=1)[:, :top_n]
part = np.take_along_axis(scores, idx, axis=1)
order = np.argsort(-part, axis=1)
top = np.take_along_axis(idx, order, axis=1)
n_users = len(uids)
out_uid = np.repeat(uids.astype(np.int64), top_n)
out_item = item_ids[top.reshape(-1)].astype(np.int64)
out_rank = np.tile(np.arange(1, top_n + 1, dtype=np.int64), n_users)
return pd.DataFrame({"uid": out_uid, "item_id": out_item, "rank": out_rank})
def validation_graded(eligibility: CandidateSet, targets: set[int]) -> dict[int, dict[int, int]]:
listens = pd.read_parquet(DATA / "val_listens.parquet")
likes = pd.read_parquet(DATA / "val_likes.parquet")
strong = listens[
listens.uid.isin(targets)
& (listens.is_organic == 1)
& (listens.played_ratio_pct >= LISTEN_STRONG)
][["uid", "item_id"]].drop_duplicates()
organic_likes = likes[likes.uid.isin(targets) & (likes.is_organic == 1)][["uid", "item_id"]].drop_duplicates()
graded: dict[int, dict[int, int]] = {}
for uid, item in strong.itertuples(index=False, name=None):
uid, item = int(uid), int(item)
if eligibility.is_eligible(uid, item):
graded.setdefault(uid, {})[item] = 1
for uid, item in organic_likes.itertuples(index=False, name=None):
uid, item = int(uid), int(item)
if eligibility.is_eligible(uid, item):
graded.setdefault(uid, {})[item] = 3
return graded
def unpack_eligibility(path: Path, n_items: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
with np.load(path, allow_pickle=False) as art:
uids = art["uids"].astype(np.int64, copy=True)
item_ids = art["item_ids"].astype(np.int64, copy=True)
packed = art["packed_eligible"]
elig = np.unpackbits(packed, axis=1, bitorder="little")[:, :n_items].astype(bool)
return uids, item_ids, elig
def tensors_from_examples(examples: dict[str, np.ndarray]) -> dict[str, torch.Tensor]:
out = {}
for k, v in examples.items():
if k in {"recent_ids", "long_ids", "like_ids", "dislike_ids", "oov_ids", "target"}:
out[k] = torch.from_numpy(np.ascontiguousarray(v, dtype=np.int64))
else:
out[k] = torch.from_numpy(np.ascontiguousarray(v, dtype=np.float32))
return out
def iterate_batches(
tensors: dict[str, torch.Tensor], batch_size: int, rng: torch.Generator
):
n = tensors["target"].shape[0]
perm = torch.randperm(n, generator=rng)
for start in range(0, n, batch_size):
idx = perm[start : start + batch_size]
if idx.numel() < 8:
continue
yield {k: v[idx] for k, v in tensors.items()}
def train_model(
model: TwoTower,
examples: dict[str, np.ndarray],
pop_probs: np.ndarray,
log_q: torch.Tensor,
epochs: int,
lr: float,
eval_fn,
deadline: float,
ckpt_path: Path,
) -> tuple[TwoTower, float, int]:
tensors = tensors_from_examples(examples)
n_items = model.n_items
pop = torch.from_numpy(pop_probs.astype(np.float32))
opt = AdamW(model.parameters(), lr=lr, weight_decay=1e-4)
steps_per = max(1, examples["target"].shape[0] // BATCH_SIZE)
sched = CosineAnnealingLR(opt, T_max=max(1, epochs * steps_per), eta_min=lr * 0.08)
gen = torch.Generator()
gen.manual_seed(SEED)
best_ndcg = -1.0
best_epoch = 0
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
for epoch in range(1, epochs + 1):
if time.time() > deadline:
log("hit time deadline during training")
break
model.train()
losses = []
t0 = time.time()
n_steps = 0
for batch in iterate_batches(tensors, BATCH_SIZE, gen):
if time.time() > deadline:
break
user_vec = model.user_vectors(batch)
extra = torch.multinomial(pop, N_NEG, replacement=True)
logits = model.sampled_logits(user_vec, batch["target"], extra, log_q=log_q)
labels = torch.zeros(logits.size(0), dtype=torch.long)
loss = F.cross_entropy(logits, labels, reduction="none")
loss = (loss * batch["weight"]).mean()
opt.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)
opt.step()
sched.step()
losses.append(float(loss.item()))
n_steps += 1
mean_loss = float(np.mean(losses)) if losses else float("nan")
ndcg = eval_fn(model) if eval_fn is not None else 0.0
log(
f"epoch {epoch} steps={n_steps} loss={mean_loss:.4f} "
f"ndcg@10={ndcg:.6f} time={time.time() - t0:.1f}s"
)
if ndcg >= best_ndcg:
best_ndcg = ndcg
best_epoch = epoch
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
torch.save(best_state, ckpt_path)
log(f"saved checkpoint ndcg={best_ndcg:.6f} -> {ckpt_path}")
elif eval_fn is None:
best_ndcg = mean_loss
best_epoch = epoch
best_state = {k: v.detach().cpu().clone() for k, v in model.state_dict().items()}
torch.save(best_state, ckpt_path)
model.load_state_dict(best_state)
return model, float(best_ndcg), best_epoch
def snapshot_list(end_ts: int, n_windows: int) -> list[int]:
snaps = []
for w in range(n_windows, 0, -1):
snap = end_ts - w * WINDOW
if snap > DAY * 14:
snaps.append(int(snap))
return snaps
def write_ranking(df: pd.DataFrame, path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
df.to_parquet(path, index=False)
log(f"wrote {path} rows={len(df)}")
def main() -> None:
t_start = time.time()
deadline = t_start + 54 * 60
rng = np.random.default_rng(SEED)
torch.manual_seed(SEED)
np.random.seed(SEED)
item_ids, item_feat, lookup = load_catalog()
n_items = len(item_ids)
vocab = 1 + n_items + N_HASH
targets_df = pd.read_parquet(DATA / "target_users.parquet")
target_uids = targets_df.uid.to_numpy(dtype=np.int64)
target_set = set(map(int, target_uids.tolist()))
val_elig_uids, val_elig_items, val_elig = unpack_eligibility(
DATA / "validation_eligible_candidates.npz", n_items
)
test_elig_uids, test_elig_items, test_elig = unpack_eligibility(
DATA / "test_eligible_candidates.npz", n_items
)
assert np.array_equal(val_elig_items, item_ids)
assert np.array_equal(test_elig_items, item_ids)
val_uid_row = {int(u): i for i, u in enumerate(val_elig_uids.tolist())}
test_uid_row = {int(u): i for i, u in enumerate(test_elig_uids.tolist())}
val_elig_aligned = np.stack([val_elig[val_uid_row[int(u)]] for u in target_uids], axis=0)
test_elig_aligned = np.stack([test_elig[test_uid_row[int(u)]] for u in target_uids], axis=0)
eligibility = CandidateSet(DATA / "validation_eligible_candidates.npz")
graded = validation_graded(eligibility, target_set)
log(f"validation scored users={len(graded)}")
recs_train = build_user_recs(["train"], lookup, n_items)
snaps = snapshot_list(TRAIN_CUTOFF, n_windows=8)
log(f"val-stage snapshots {snaps}")
examples = collect_examples(recs_train, snaps, MAX_EXAMPLES_VAL, rng)
target_hist = np.bincount(examples["target"], minlength=n_items).astype(np.float64)
pop = np.power(np.maximum(target_hist, 0.5), 0.75)
pop_probs = pop / pop.sum()
q = np.maximum(target_hist, 0.5)
q = q / q.sum()
log_q = torch.from_numpy(np.log(q).astype(np.float32))
val_pref = preference_overrides(
DATA / "validation_preference_state.parquet", lookup, n_items, recs_train, TRAIN_CUTOFF
)
def eval_val(model: TwoTower) -> float:
model.eval()
with torch.no_grad():
item_vecs = model.all_item_vectors().cpu().numpy()
user_vecs = encode_users(model, recs_train, target_uids, TRAIN_CUTOFF, val_pref)
ranking = rank_users(user_vecs, item_vecs, val_elig_aligned, item_ids, target_uids)
ranked = {
int(uid): list(map(int, grp.sort_values("rank").item_id.tolist()))
for uid, grp in ranking.groupby("uid")
}
scores = score_rankings(ranked, graded)
return float(scores["quality_ndcg@10"])
model = TwoTower(
vocab_size=vocab,
n_items=n_items,
n_feat=item_feat.shape[1],
d_model=D_MODEL,
n_stats=N_STATS,
dropout=0.1,
)
model.set_item_features(torch.from_numpy(item_feat))
n_params = sum(p.numel() for p in model.parameters())
log(f"model params={n_params} vocab={vocab} items={n_items}")
val_deadline = min(deadline - 16 * 60, t_start + 28 * 60)
ckpt = SOLUTION / "best_val.pt"
model, best_ndcg, best_epoch = train_model(
model,
examples,
pop_probs,
log_q,
epochs=6,
lr=1.5e-3,
eval_fn=eval_val,
deadline=val_deadline,
ckpt_path=ckpt,
)
log(f"best val ndcg@10={best_ndcg:.6f} epoch={best_epoch}")
model.eval()
with torch.no_grad():
item_vecs = model.all_item_vectors().cpu().numpy()
user_vecs = encode_users(model, recs_train, target_uids, TRAIN_CUTOFF, val_pref)
val_ranking = rank_users(user_vecs, item_vecs, val_elig_aligned, item_ids, target_uids)
write_ranking(val_ranking, SOLUTION / "validation.parquet")
(SOLUTION / "val_score.json").write_text(
json.dumps({"quality_ndcg@10": best_ndcg, "best_epoch": best_epoch}, indent=2)
)
recs_full = build_user_recs(["train", "val"], lookup, n_items)
val_end = 25_393_400
test_cutoff = val_end + 1800
test_pref = preference_overrides(
DATA / "test_preference_state.parquet", lookup, n_items, recs_full, test_cutoff
)
# Fallback submission from the val-selected model with test-time histories.
user_vecs_test = encode_users(model, recs_full, target_uids, test_cutoff, test_pref)
with torch.no_grad():
item_vecs = model.all_item_vectors().cpu().numpy()
fallback = rank_users(user_vecs_test, item_vecs, test_elig_aligned, item_ids, target_uids)
write_ranking(fallback, SOLUTION / "submission.parquet")
remaining = deadline - time.time()
log(f"remaining seconds before refit {remaining:.0f}")
if remaining < 10 * 60:
log("skipping full refit; keeping fallback submission")
else:
snaps_full = snapshot_list(val_end, n_windows=9)
log(f"refit snapshots {snaps_full}")
examples_full = collect_examples(recs_full, snaps_full, MAX_EXAMPLES_REFIT, rng)
target_hist = np.bincount(examples_full["target"], minlength=n_items).astype(np.float64)
pop = np.power(np.maximum(target_hist, 0.5), 0.75)
pop_probs = pop / pop.sum()
q = np.maximum(target_hist, 0.5)
q = q / q.sum()
log_q = torch.from_numpy(np.log(q).astype(np.float32))
refit = TwoTower(
vocab_size=vocab,
n_items=n_items,
n_feat=item_feat.shape[1],
d_model=D_MODEL,
n_stats=N_STATS,
dropout=0.1,
)
refit.set_item_features(torch.from_numpy(item_feat))
if ckpt.exists():
refit.load_state_dict(torch.load(ckpt, map_location="cpu"))
log("warm-started refit from best val checkpoint")
refit_epochs = max(2, min(4, best_epoch if best_epoch else 3))
refit, _, _ = train_model(
refit,
examples_full,
pop_probs,
log_q,
epochs=refit_epochs,
lr=8e-4,
eval_fn=None,
deadline=deadline,
ckpt_path=SOLUTION / "best_refit.pt",
)
refit.eval()
with torch.no_grad():
item_vecs = refit.all_item_vectors().cpu().numpy()
user_vecs_test = encode_users(refit, recs_full, target_uids, test_cutoff, test_pref)
submission = rank_users(user_vecs_test, item_vecs, test_elig_aligned, item_ids, target_uids)
write_ranking(submission, SOLUTION / "submission.parquet")
model = refit
log(f"done in {time.time() - t_start:.1f}s val_ndcg={best_ndcg:.6f}")
(SOLUTION / "run_meta.json").write_text(
json.dumps(
{
"quality_ndcg@10": best_ndcg,
"best_epoch": best_epoch,
"seconds": time.time() - t_start,
"d_model": D_MODEL,
},
indent=2,
)
)
if __name__ == "__main__":
main()