Harness context codex item completed
{
"type": "codex_item_completed",
"item": {
"type": "CommandExecution",
"id": "exec-f7c0b76c-4531-4c58-a113-5fe4daf863d1",
"process_id": "83804",
"command": [
"/bin/sh",
"-lc",
"rg -n \"negative|def train|argparse|Config\\(|rank|validation\" /task/current_model/train_current_model.py | head -160 && sed -n '300,700p' /task/current_model/two_tower_model.py && sed -n '500,1050p' /task/current_model/train_current_model.py && sed -n '1050,1550p' /task/current_model/train_current_model.py"
],
"cwd": "file:///app",
"parsed_cmd": [
{
"type": "search",
"cmd": "rg -n \"negative|def train|argparse|Config\\\\(|rank|validation\" /task/current_model/train_current_model.py",
"query": "negative|def train|argparse|Config\\(|rank|validation",
"path": "train_current_model.py"
},
{
"type": "read",
"cmd": "sed -n '300,700p' /task/current_model/two_tower_model.py",
"name": "two_tower_model.py",
"path": "/task/current_model/two_tower_model.py"
},
{
"type": "read",
"cmd": "sed -n '500,1050p' /task/current_model/train_current_model.py",
"name": "train_current_model.py",
"path": "/task/current_model/train_current_model.py"
},
{
"type": "read",
"cmd": "sed -n '1050,1550p' /task/current_model/train_current_model.py",
"name": "train_current_model.py",
"path": "/task/current_model/train_current_model.py"
}
],
"source": "unified_exec_startup",
"status": "completed",
"stdout": "7:16 held-out validation targets; passing zero for either cap restores the\n14:and 24 uniform plus eight propensity-corrected unique in-batch negatives. Every\n20:import argparse\n56: uniform_negatives: int = 24\n57: inbatch_negatives: int = 8\n61: negative_filter_scope: str = \"full-training-history\"\n67: validation_fraction: float = 0.10\n69: max_validation_targets_per_user: int = 16\n132: validation_counts: np.ndarray\n137: return int(self.training_counts.sum() + self.validation_counts.sum())\n152: return base.TowerConfig(\n165: uniform_negatives=config.uniform_negatives,\n166: inbatch_negatives=config.inbatch_negatives,\n346: \"popularity_rank\"\n354: if stage == \"validation\":\n357: candidate_name = \"validation_eligible_candidates.npz\"\n358: state_name = \"validation_preference_state.parquet\"\n477: \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n518: \"validation_eligible_candidates.npz\"\n519: if stage == \"validation\"\n603: f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n609:def invalid_full_history_negatives(\n610: negatives: np.ndarray,\n616: \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n617: byte_columns = negatives >> 3\n618: bit_columns = negatives & 7\n621: invalid |= negatives == targets[:, None]\n622: invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n623: for column in range(1, negatives.shape[1]):\n625: negatives[:, column, None] == negatives[:, :column]\n630:def draw_full_history_filtered_negatives(\n641: \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n647: (batch_size, config.uniform_negatives),\n653: if config.inbatch_negatives:\n656: config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n672: if corrected_count < config.inbatch_negatives:\n675: (batch_size, config.inbatch_negatives - corrected_count),\n681: negatives = torch.cat([uniform, inbatch], dim=1).numpy()\n682: correction = np.ones_like(negatives, dtype=np.float32)\n685: :, config.uniform_negatives : config.uniform_negatives + corrected_count\n688: invalid = invalid_full_history_negatives(\n689: negatives,\n697: torch.from_numpy(negatives).to(device),\n706: negatives[invalid] = replacements\n709: invalid = invalid_full_history_negatives(\n710: negatives,\n717: raise RuntimeError(\"full-history negative resampling failed\")\n719: torch.from_numpy(negatives).to(device),\n724:def draw_training_negatives(\n736: if config.negative_filter_scope == \"full-training-history\":\n738: raise RuntimeError(\"full-history negative filter was not built\")\n739: return draw_full_history_filtered_negatives(\n750: return base.draw_filtered_mixed_negatives(\n755: config.uniform_negatives,\n756: config.inbatch_negatives,\n816: negatives: torch.Tensor,\n818: all_items = torch.cat([positives, negatives.reshape(-1)])\n822: negative_vectors = encoded[inverse[len(positives) :]].reshape(\n823: len(positives), negatives.shape[1], -1\n825: return positive_vectors, negative_vectors\n845:def corrected_negative_loss(\n851: total = config.uniform_negatives + config.inbatch_negatives\n853: if config.uniform_negatives:\n854: result += config.uniform_negatives * raw[\n855: :, : config.uniform_negatives\n857: if config.inbatch_negatives:\n858: selected = slice(config.uniform_negatives, total)\n860: result += config.inbatch_negatives * (\n869: validation_counts: np.ndarray,\n873: training_region_ends = total_targets - validation_counts\n878: or np.any(validation_counts < 0)\n880: raise RuntimeError(\"chronological target partition produced negative counts\")\n882: window_starts + training_counts + validation_counts, total_targets\n890: validation_fraction: float,\n892: max_validation_targets_per_user: int,\n896: validation = np.floor(totals * validation_fraction).astype(np.int64)\n897: validation[(totals >= 2) & (validation == 0)] = 1\n898: if max_validation_targets_per_user:\n899: validation = np.minimum(validation, max_validation_targets_per_user)\n901: history_training = totals - validation\n905: window_starts = retained_target_window_starts(totals, validation, training)\n909: validation_counts=validation,\n918: prefixes = (\"train\",) if stage == \"validation\" else (\"train\", \"val\")\n981: base.TRAIN_END if stage == \"validation\" else base.VALIDATION_END\n991: first_validation_target = last_training_target + 1\n993: int(prepared.sequence_timestamps[first_validation_target])\n994: if first_validation_target < end\n1119: negatives: torch.Tensor,\n1120: negative_weights: torch.Tensor,\n1134: \"negative_sum\": 0.0,\n1162: positive_vectors, negative_vectors = candidate_vectors(\n1165: negatives[begin:end],\n1168: negative_logits = torch.einsum(\n1169: \"bd,bkd->bk\", query_vectors, negative_vectors\n1172: negative_loss = corrected_negative_loss(\n1173: negative_logits, negative_weights[begin:end], config\n1179: selected_weights * (positive_loss + negative_loss) / 2.0\n1190: statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n1192: (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n1227: negative_count = config.uniform_negatives + config.inbatch_negatives\n1232: total_negative = 0.0\n1293: negatives, negative_weights = draw_training_negatives(\n1314: negatives,\n1315: negative_weights,\n1331: total_negative += statistics[\"negative_sum\"]\n1353: \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n1355: / max(total_targets * negative_count, 1),\n1364:def evaluate_validation_bce(\n1371: validation_counts: np.ndarray,\n1378: totals = training_counts + validation_counts\n1379: users_with_validation = np.flatnonzero(validation_counts > 0)\n1380: ordered = users_with_validation[\n1381: np.argsort(totals[users_with_validation], kind=\"stable\")\n1449: negatives, negative_weights = draw_training_negatives(\n1469: negatives,\n1470: negative_weights,\n1481: \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n1482: \"validation_targets\": float(total_targets),\n1483: \"validation_target_weight_sum\": total_weight,\n1484: \"validation_seconds\": time.monotonic() - started,\n1521:def train_model(\n1532: config.validation_fraction,\n1534: config.max_validation_targets_per_user,\n1537: validation_counts = partition.validation_counts\n1540: f\"validation={int(validation_counts.sum()):,} \"\n1567: if config.negative_filter_scope == \"full-training-history\"\n1601: \"negative_filter_scope\", \"last-100\"\n1619: stored_config.get(\"validation_fraction\", 0.10),\n1621: stored_config.get(\"max_validation_targets_per_user\", 0),\n1624: config.validation_fraction,\n1626: config.max_validation_targets_per_user,\n1629: stored_filter_scope != config.negative_filter_scope\n1657: prior.pop(\"validation_loss\", None)\n1660: f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n1664: \"recomputing the resume checkpoint's validation BCE\"\n1666: if \"validation_loss\" not in prior:\n1668: evaluate_validation_bce(\n1675: validation_counts,\n1682: best_loss = float(prior[\"validation_loss\"])\n1686: f\"resumed validation-BCE training: epoch={start_epoch} \"\n1705: validation = evaluate_validation_bce(\n1712: validation_counts,\n1717: diagnostics.update(validation)\n1731: diagnostics[\"validation_loss\"]\n1735: best_loss = diagnostics[\"validation_loss\"]\n1755: f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n1758: f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n1760: f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n1766: f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n1775: f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n1861:def rank_candidates(\n1867: ranking_batch_size: int,\n1882: for start in range(0, len(eligibility.uids), ranking_batch_size):\n1883: end = min(start + ranking_batch_size, len(eligibility.uids))\n1896: (uid, int(prepared.item_ids[column]), rank)\n1897: for rank, column in enumerate(columns, start=1)\n1900: log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n1909: return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n1912:def write_ranking(frame: pd.DataFrame, output: Path) -> None:\n1923: parser = argparse.ArgumentParser(description=__doc__)\n input_size=config.hidden_dim,\n hidden_size=dim,\n num_layers=1,\n batch_first=True,\n )\n self.user_tower = nn.Sequential(\n nn.Linear(dim * 3 + 6, config.user_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_1, config.user_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_2, dim),\n )\n self.candidate_embedding = nn.Embedding(n_items, dim)\n self.register_buffer(\n \"item_features\", torch.from_numpy(item_features), persistent=True\n )\n self.item_tower = nn.Sequential(\n nn.Linear(dim + n_item_features, config.item_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_1, config.item_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_2, dim),\n )\n nn.init.normal_(self.context_embedding.weight, std=0.02)\n nn.init.normal_(self.candidate_embedding.weight, std=0.02)\n with torch.no_grad():\n self.context_embedding.weight[n_items].zero_()\n\n def _mean_context_channel(self, items: torch.Tensor) -> torch.Tensor:\n mask = items.ne(self.n_items)\n vectors = self.context_embedding(items)\n return (vectors * mask.unsqueeze(-1)).sum(dim=1) / mask.sum(\n dim=1, keepdim=True\n ).clamp_min(1)\n\n def encode_items(self, items: torch.Tensor) -> torch.Tensor:\n inputs = torch.cat(\n [self.candidate_embedding(items), self.item_features[items]], dim=1\n )\n return self.item_tower(inputs)\n\n\ndef invalid_negative_mask(\n negatives: torch.Tensor,\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n) -> torch.Tensor:\n \"\"\"Identify targets, known positives, active likes, and row duplicates.\"\"\"\n invalid = negatives.eq(targets[:, None])\n invalid |= negatives[:, :, None].eq(context_items[:, None, :]).any(dim=2)\n invalid |= negatives[:, :, None].eq(liked_items[:, None, :]).any(dim=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= negatives[:, column, None].eq(\n negatives[:, :column]\n ).any(dim=1)\n return invalid\n\n\ndef draw_filtered_mixed_negatives(\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n n_items: int,\n uniform_count: int,\n inbatch_count: int,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw uniform and unique-target in-batch negatives.\"\"\"\n batch_size = len(targets)\n uniform = torch.randint(\n n_items,\n (batch_size, uniform_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long, device=\"cpu\")\n corrected_count = 0\n if inbatch_count:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(inbatch_count, max(len(unique_targets) - 1, 0))\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n )\n priorities.masked_fill_(\n unique_targets[None, :].eq(targets.detach().cpu()[:, None]),\n -1.0,\n )\n columns = torch.topk(priorities, k=corrected_count, dim=1).indices\n inbatch = unique_targets[columns]\n if corrected_count < inbatch_count:\n filler = torch.randint(\n n_items,\n (batch_size, inbatch_count - corrected_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.cat([inbatch, filler], dim=1)\n\n negatives_cpu = torch.cat([uniform, inbatch], dim=1)\n correction_cpu = torch.ones_like(negatives_cpu, dtype=torch.float32)\n if corrected_count:\n correction_cpu[\n :, uniform_count : uniform_count + corrected_count\n ] = item_correction[inbatch[:, :corrected_count]]\n negatives = negatives_cpu.to(device)\n correction = correction_cpu.to(device)\n for _ in range(32):\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if not bool(invalid.any()):\n return negatives, correction\n replacements = torch.randint(\n n_items,\n (int(invalid.sum().detach().cpu()),),\n generator=generator,\n device=\"cpu\",\n ).to(device)\n negatives[invalid] = replacements\n correction[invalid] = 1.0\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if bool(invalid.any()):\n raise RuntimeError(\"mixed negative resampling failed false-negative filter\")\n return negatives, correction\n field: artifact[field].copy()\n for field in SequenceData.__dataclass_fields__\n }\n prepared = SequenceData(**values)\n log(\n f\"loaded full-sequence cache: tokens={len(prepared.sequence_items):,} \"\n f\"targets={prepared.target_count:,}\"\n )\n return prepared\n\n\ndef load_or_prepare(\n public: Path,\n stage: str,\n config: Config,\n cache: Path | None,\n) -> tuple[SequenceData, CandidateSet]:\n candidate_name = (\n \"validation_eligible_candidates.npz\"\n if stage == \"validation\"\n else \"test_eligible_candidates.npz\"\n )\n eligibility = CandidateSet.read(public / \"data\" / candidate_name)\n if cache is not None and cache.is_file():\n prepared = load_cache(cache, stage, config)\n if not np.array_equal(prepared.item_ids, eligibility.item_ids):\n raise RuntimeError(\"cached catalog differs from candidate artifact\")\n return prepared, eligibility\n prepared, built_eligibility = prepare_data(public, stage, config)\n if cache is not None:\n save_cache(cache, prepared, stage, config)\n return prepared, built_eligibility\n\n\ndef build_item_correction(\n prepared: SequenceData,\n config: Config,\n target_starts: np.ndarray,\n target_counts: np.ndarray,\n) -> torch.Tensor:\n \"\"\"Estimate in-batch propensities from the selected training targets.\"\"\"\n counts = np.zeros(len(prepared.item_ids), dtype=np.float64)\n for user_row, count in enumerate(target_counts):\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n target_start = int(target_starts[user_row])\n counts += np.bincount(\n prepared.sequence_items[\n offset\n + target_start\n + 1 : offset\n + target_start\n + int(count)\n + 1\n ].astype(np.int64),\n minlength=len(prepared.item_ids),\n )\n total = float(counts.sum())\n probabilities = (counts + 1.0) / (total + len(counts))\n raw = np.power(\n (1.0 / len(counts)) / probabilities,\n config.inbatch_correction_power,\n )\n normalization = float(np.sum(counts * raw) / max(total, 1.0))\n correction = np.clip(\n raw / max(normalization, 1.0e-12),\n config.inbatch_correction_min,\n config.inbatch_correction_max,\n ).astype(np.float32)\n mean_on_targets = float(np.sum(counts * correction) / max(total, 1.0))\n log(\n f\"inbatch correction: min={correction.min():.3f} \"\n f\"mean_on_targets={mean_on_targets:.3f} max={correction.max():.3f}\"\n )\n return torch.from_numpy(correction)\n\n\ndef build_known_positive_filter(\n prepared: SequenceData, training_counts: np.ndarray\n) -> np.ndarray:\n \"\"\"Pack each user's complete public-training positive-item set.\"\"\"\n width = (len(prepared.item_ids) + 7) // 8\n packed = np.zeros((len(prepared.user_ids), width), dtype=np.uint8)\n retained = 0\n for user_row, target_count in enumerate(training_counts):\n if not target_count:\n continue\n offset = int(prepared.offsets[user_row])\n # T adjacent targets consume sequence positions 0 through T. The\n # chronological BCE suffix starts at T + 1 and is intentionally absent.\n items = np.unique(\n prepared.sequence_items[\n offset : offset + int(target_count) + 1\n ].astype(np.int64)\n )\n byte_columns = items >> 3\n bit_values = np.left_shift(\n np.uint8(1), (items & 7).astype(np.uint8)\n )\n np.bitwise_or.at(packed[user_row], byte_columns, bit_values)\n retained += len(items)\n log(\n f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n f\"unique_user_items={retained:,} bytes={packed.nbytes:,}\"\n )\n return packed\n\n\ndef invalid_full_history_negatives(\n negatives: np.ndarray,\n targets: np.ndarray,\n user_rows: np.ndarray,\n known_positive_packed: np.ndarray,\n liked_items: np.ndarray,\n) -> np.ndarray:\n \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n byte_columns = negatives >> 3\n bit_columns = negatives & 7\n known = known_positive_packed[user_rows[:, None], byte_columns]\n invalid = ((known >> bit_columns) & 1).astype(bool)\n invalid |= negatives == targets[:, None]\n invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= (\n negatives[:, column, None] == negatives[:, :column]\n ).any(axis=1)\n return invalid\n\n\ndef draw_full_history_filtered_negatives(\n targets: torch.Tensor,\n user_rows: np.ndarray,\n liked_items: torch.Tensor,\n known_positive_packed: np.ndarray,\n n_items: int,\n config: Config,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n targets_cpu = targets.detach().cpu().numpy().astype(np.int64, copy=False)\n liked_cpu = liked_items.detach().cpu().numpy().astype(np.int64, copy=False)\n batch_size = len(targets_cpu)\n uniform = torch.randint(\n n_items,\n (batch_size, config.uniform_negatives),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long)\n corrected_count = 0\n if config.inbatch_negatives:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(\n config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n )\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n )\n priorities.masked_fill_(\n unique_targets[None, :].eq(\n torch.from_numpy(targets_cpu)[:, None]\n ),\n -1.0,\n )\n columns = torch.topk(\n priorities, k=corrected_count, dim=1\n ).indices\n inbatch = unique_targets[columns]\n if corrected_count < config.inbatch_negatives:\n filler = torch.randint(\n n_items,\n (batch_size, config.inbatch_negatives - corrected_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.cat([inbatch, filler], dim=1)\n\n negatives = torch.cat([uniform, inbatch], dim=1).numpy()\n correction = np.ones_like(negatives, dtype=np.float32)\n if corrected_count:\n correction[\n :, config.uniform_negatives : config.uniform_negatives + corrected_count\n ] = item_correction[inbatch[:, :corrected_count]].numpy()\n for _ in range(32):\n invalid = invalid_full_history_negatives(\n negatives,\n targets_cpu,\n user_rows,\n known_positive_packed,\n liked_cpu,\n )\n if not np.any(invalid):\n return (\n torch.from_numpy(negatives).to(device),\n torch.from_numpy(correction).to(device),\n )\n replacements = torch.randint(\n n_items,\n (int(invalid.sum()),),\n generator=generator,\n device=\"cpu\",\n ).numpy()\n negatives[invalid] = replacements\n # Rejected in-batch items become ordinary uniform draws.\n correction[invalid] = 1.0\n invalid = invalid_full_history_negatives(\n negatives,\n targets_cpu,\n user_rows,\n known_positive_packed,\n liked_cpu,\n )\n if np.any(invalid):\n raise RuntimeError(\"full-history negative resampling failed\")\n return (\n torch.from_numpy(negatives).to(device),\n torch.from_numpy(correction).to(device),\n )\n\n\ndef draw_training_negatives(\n positive_items: torch.Tensor,\n known_history: torch.Tensor,\n liked_items: torch.Tensor,\n user_rows: np.ndarray,\n known_positive_packed: np.ndarray | None,\n prepared: SequenceData,\n config: Config,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n if config.negative_filter_scope == \"full-training-history\":\n if known_positive_packed is None:\n raise RuntimeError(\"full-history negative filter was not built\")\n return draw_full_history_filtered_negatives(\n positive_items,\n user_rows,\n liked_items,\n known_positive_packed,\n len(prepared.item_ids),\n config,\n item_correction,\n generator,\n device,\n )\n return base.draw_filtered_mixed_negatives(\n positive_items,\n known_history,\n liked_items,\n len(prepared.item_ids),\n config.uniform_negatives,\n config.inbatch_negatives,\n item_correction,\n generator,\n device,\n )\n\n\ndef event_sequence(\n model: base.SequentialTwoTower,\n items: torch.Tensor,\n features: torch.Tensor,\n hidden: torch.Tensor,\n) -> tuple[torch.Tensor, torch.Tensor]:\n inputs = torch.cat([model.context_embedding(items), features], dim=-1)\n projected = model.event_projection(inputs)\n return model.sequence_encoder(projected, hidden)\n\n\ndef pool_explicit_states(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n state_ids: np.ndarray,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n unique_states, inverse = np.unique(state_ids, return_inverse=True)\n liked_items = torch.from_numpy(\n prepared.liked_states[unique_states].astype(np.int64)\n ).to(device)\n disliked_items = torch.from_numpy(\n prepared.disliked_states[unique_states].astype(np.int64)\n ).to(device)\n liked_vectors = model._mean_context_channel(liked_items)\n disliked_vectors = model._mean_context_channel(disliked_items)\n inverse_rows = torch.from_numpy(inverse.astype(np.int64)).to(device)\n counts = prepared.state_counts[state_ids]\n return (\n liked_vectors[inverse_rows],\n disliked_vectors[inverse_rows],\n torch.from_numpy(counts.astype(np.float32)).to(device),\n )\n\n\ndef user_queries(\n model: base.SequentialTwoTower,\n sequence_vectors: torch.Tensor,\n liked_vectors: torch.Tensor,\n disliked_vectors: torch.Tensor,\n user_features: torch.Tensor,\n) -> torch.Tensor:\n return model.user_tower(\n torch.cat(\n [sequence_vectors, liked_vectors, disliked_vectors, user_features],\n dim=1,\n )\n )\n\n\ndef candidate_vectors(\n model: base.SequentialTwoTower,\n positives: torch.Tensor,\n negatives: torch.Tensor,\n) -> tuple[torch.Tensor, torch.Tensor]:\n all_items = torch.cat([positives, negatives.reshape(-1)])\n unique_items, inverse = torch.unique(all_items, return_inverse=True)\n encoded = model.encode_items(unique_items)\n positive_vectors = encoded[inverse[: len(positives)]]\n negative_vectors = encoded[inverse[len(positives) :]].reshape(\n len(positives), negatives.shape[1], -1\n )\n return positive_vectors, negative_vectors\n\n\ndef history_windows(\n sequence: np.ndarray,\n start: int,\n count: int,\n width: int,\n padding_item: int,\n) -> np.ndarray:\n base_start = max(0, start - width + 1)\n segment = sequence[base_start : start + count]\n padded = np.concatenate(\n [np.full(width - 1, padding_item, dtype=np.uint16), segment]\n )\n windows = np.lib.stride_tricks.sliding_window_view(padded, width)\n offset = start - base_start\n return windows[offset : offset + count].copy()\n\n\ndef corrected_negative_loss(\n logits: torch.Tensor,\n correction: torch.Tensor,\n config: Config,\n) -> torch.Tensor:\n raw = F.softplus(logits)\n total = config.uniform_negatives + config.inbatch_negatives\n result = torch.zeros(len(logits), device=logits.device)\n if config.uniform_negatives:\n result += config.uniform_negatives * raw[\n :, : config.uniform_negatives\n ].mean(dim=1)\n if config.inbatch_negatives:\n selected = slice(config.uniform_negatives, total)\n weights = correction[:, selected]\n result += config.inbatch_negatives * (\n (raw[:, selected] * weights).sum(dim=1)\n / weights.sum(dim=1).clamp_min(1.0e-6)\n )\n return result / total\n\n\ndef retained_target_window_starts(\n total_targets: np.ndarray,\n validation_counts: np.ndarray,\n training_counts: np.ndarray,\n) -> np.ndarray:\n \"\"\"Return the discarded prefix before the retained chronological suffix.\"\"\"\n training_region_ends = total_targets - validation_counts\n window_starts = training_region_ends - training_counts\n if (\n np.any(window_starts < 0)\n or np.any(training_counts < 0)\n or np.any(validation_counts < 0)\n ):\n raise RuntimeError(\"chronological target partition produced negative counts\")\n if not np.array_equal(\n window_starts + training_counts + validation_counts, total_targets\n ):\n raise RuntimeError(\"chronological target window is not a final contiguous suffix\")\n return window_starts\n\n\ndef chronological_target_partition(\n prepared: SequenceData,\n validation_fraction: float,\n max_train_targets_per_user: int,\n max_validation_targets_per_user: int,\n) -> TargetPartition:\n \"\"\"Select a recent contiguous window with a final out-of-time BCE suffix.\"\"\"\n totals = np.maximum(prepared.lengths - 1, 0).astype(np.int64)\n validation = np.floor(totals * validation_fraction).astype(np.int64)\n validation[(totals >= 2) & (validation == 0)] = 1\n if max_validation_targets_per_user:\n validation = np.minimum(validation, max_validation_targets_per_user)\n\n history_training = totals - validation\n training = history_training.copy()\n if max_train_targets_per_user:\n training = np.minimum(training, max_train_targets_per_user)\n window_starts = retained_target_window_starts(totals, validation, training)\n return TargetPartition(\n window_starts=window_starts,\n training_counts=training,\n validation_counts=validation,\n history_training_counts=history_training,\n )\n\n\ndef load_organic_like_outcomes(\n public: Path, stage: str, item_ids: np.ndarray\n) -> dict[int, dict[int, np.ndarray]]:\n \"\"\"Load mapped organic-like timestamps from public fitting history.\"\"\"\n prefixes = (\"train\",) if stage == \"validation\" else (\"train\", \"val\")\n frames = [\n pd.read_parquet(\n public / \"data\" / f\"{prefix}_likes.parquet\",\n columns=[\"uid\", \"timestamp\", \"item_id\", \"is_organic\"],\n )\n for prefix in prefixes\n ]\n likes = pd.concat(frames, ignore_index=True)\n likes = likes[likes.is_organic == 1].copy()\n mapped = pd.Index(item_ids).get_indexer(\n likes.item_id.to_numpy(dtype=np.int64)\n )\n likes = likes.loc[mapped >= 0, [\"uid\", \"timestamp\"]].copy()\n likes[\"item\"] = mapped[mapped >= 0].astype(np.int32, copy=False)\n likes = likes.sort_values([\"uid\", \"item\", \"timestamp\"], kind=\"stable\")\n nested: dict[int, dict[int, list[int]]] = {}\n for raw_uid, raw_timestamp, raw_item in likes.itertuples(\n index=False, name=None\n ):\n nested.setdefault(int(raw_uid), {}).setdefault(int(raw_item), []).append(\n int(raw_timestamp)\n )\n result = {\n uid: {\n item: np.asarray(timestamps, dtype=np.int64)\n for item, timestamps in items.items()\n }\n for uid, items in nested.items()\n }\n log(\n f\"loaded organic-like outcomes: rows={len(likes):,} \"\n f\"users={len(result):,}\"\n )\n return result\n\n\ndef build_target_weights(\n public: Path,\n stage: str,\n prepared: SequenceData,\n training_counts: np.ndarray,\n config: Config,\n) -> np.ndarray:\n \"\"\"Build nonzero per-token weights without dropping adjacent targets.\"\"\"\n if config.target_weighting == \"uniform\":\n weights = np.ones(len(prepared.sequence_items), dtype=np.float32)\n else:\n weights = np.full(\n len(prepared.sequence_items),\n config.ordinary_target_weight,\n dtype=np.float32,\n )\n organic_strong = (\n (prepared.event_features[:, 0] > 0.5)\n & (prepared.event_features[:, 1] >= 0.8)\n )\n weights[organic_strong] = config.organic_strong_target_weight\n if config.target_weighting == \"discovery-graded\":\n like_outcomes = load_organic_like_outcomes(\n public, stage, prepared.item_ids\n )\n history_end = (\n base.TRAIN_END if stage == \"validation\" else base.VALIDATION_END\n )\n horizon = config.discovery_horizon_days * base.DAY\n grade_three = 0\n for user_row, uid in enumerate(prepared.user_ids):\n start = int(prepared.offsets[user_row])\n end = int(prepared.offsets[user_row + 1])\n if end - start < 2:\n continue\n last_training_target = start + int(training_counts[user_row])\n first_validation_target = last_training_target + 1\n split_timestamp = (\n int(prepared.sequence_timestamps[first_validation_target])\n if first_validation_target < end\n else history_end\n )\n user_likes = like_outcomes.get(int(uid), {})\n seen = {int(prepared.sequence_items[start])}\n for position in range(start + 1, end):\n item = int(prepared.sequence_items[position])\n novel = item not in seen\n seen.add(item)\n if not novel:\n # Repeats remain training examples, but they cannot be\n # eligible discovery outcomes at the corresponding\n # cutoff and therefore keep only the ordinary weight.\n weights[position] = config.ordinary_target_weight\n continue\n timestamps = user_likes.get(item)\n if timestamps is None:\n continue\n timestamp = int(prepared.sequence_timestamps[position])\n upper = min(\n timestamp + horizon,\n split_timestamp\n if position <= last_training_target\n else history_end,\n )\n outcome = int(np.searchsorted(timestamps, timestamp, side=\"left\"))\n if outcome < len(timestamps) and int(timestamps[outcome]) < upper:\n weights[position] = config.organic_like_target_weight\n grade_three += 1\n log(\n f\"grade-3 first-encounter targets={grade_three:,} \"\n f\"horizon_days={config.discovery_horizon_days}\"\n )\n log(\n f\"target weighting={config.target_weighting} \"\n f\"min={weights.min():.3f} mean={weights.mean():.3f} \"\n f\"max={weights.max():.3f}\"\n )\n return weights\n\n\ndef build_dense_block(\n prepared: SequenceData,\n target_weights: np.ndarray,\n users: np.ndarray,\n window_starts: np.ndarray,\n process_counts: np.ndarray,\n score_starts: np.ndarray,\n start: int,\n steps: int,\n config: Config,\n) -> tuple[np.ndarray, ...]:\n \"\"\"Build one causal sequence block and mark the target rows to score.\"\"\"\n batch_size = len(users)\n padding_item = len(prepared.item_ids)\n input_items = np.full((batch_size, steps), padding_item, dtype=np.uint16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n activity_features = np.zeros((batch_size, steps, 4), dtype=np.float16)\n state_ids = np.zeros((batch_size, steps), dtype=np.uint32)\n targets = np.zeros((batch_size, steps), dtype=np.uint16)\n weights = np.ones((batch_size, steps), dtype=np.float32)\n scored = np.zeros((batch_size, steps), dtype=bool)\n histories = np.full(\n (batch_size, steps, config.bptt_steps),\n padding_item,\n dtype=np.uint16,\n )\n processed = np.zeros(batch_size, dtype=np.int32)\n\n lengths = prepared.lengths\n for row, user_row in enumerate(users):\n count = min(steps, max(int(process_counts[row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n window_start = int(window_starts[row])\n absolute_start = window_start + start\n positions = slice(\n offset + absolute_start,\n offset + absolute_start + count,\n )\n target_positions = slice(\n offset + absolute_start + 1,\n offset + absolute_start + count + 1,\n )\n input_items[row, :count] = prepared.sequence_items[positions]\n event_features[row, :count] = prepared.event_features[positions]\n activity_features[row, :count] = prepared.activity_features[positions]\n state_ids[row, :count] = prepared.state_ids[positions]\n targets[row, :count] = prepared.sequence_items[target_positions]\n weights[row, :count] = target_weights[target_positions]\n score_from = min(count, max(int(score_starts[row]) - start, 0))\n scored[row, score_from:count] = True\n processed[row] = count\n user_sequence = prepared.sequence_items[\n offset : offset + int(lengths[user_row])\n ]\n histories[row, :count] = history_windows(\n user_sequence,\n absolute_start,\n count,\n config.bptt_steps,\n padding_item,\n )\n return (\n input_items,\n event_features,\n activity_features,\n state_ids,\n targets,\n scored,\n histories,\n processed,\n weights,\n )\n\n\ndef score_target_microbatches(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n sequence_vectors: torch.Tensor,\n flat_states: np.ndarray,\n flat_activity: np.ndarray,\n positive_items: torch.Tensor,\n negatives: torch.Tensor,\n negative_weights: torch.Tensor,\n sample_weights: np.ndarray,\n device: torch.device,\n backward: bool,\n) -> tuple[dict[str, float], torch.Tensor | None]:\n \"\"\"Compute one logical block's exact BCE in bounded-memory slices.\"\"\"\n target_count = len(positive_items)\n total_weight = float(sample_weights.sum())\n if total_weight <= 0:\n raise RuntimeError(\"target block has no positive sample weight\")\n sequence_gradient = torch.zeros_like(sequence_vectors) if backward else None\n statistics = {\n \"loss_sum\": 0.0,\n \"positive_sum\": 0.0,\n \"negative_sum\": 0.0,\n \"pair_correct\": 0.0,\n \"weight_sum\": 0.0,\n }\n for begin in range(0, target_count, config.loss_microbatch_size):\n end = min(begin + config.loss_microbatch_size, target_count)\n selected_states = flat_states[begin:end]\n if backward:\n sequence_input = (\n sequence_vectors[begin:end].detach().requires_grad_(True)\n )\n else:\n sequence_input = sequence_vectors[begin:end]\n liked_vectors, disliked_vectors, _ = pool_explicit_states(\n model, prepared, selected_states, device\n )\n counts = prepared.state_counts[selected_states]\n numeric = np.zeros((end - begin, 6), dtype=np.float32)\n numeric[:, :4] = flat_activity[begin:end]\n numeric[:, 4] = np.log1p(counts[:, 0]) / 6.0\n numeric[:, 5] = np.log1p(counts[:, 1]) / 4.0\n query_vectors = user_queries(\n model,\n sequence_input,\n liked_vectors,\n disliked_vectors,\n torch.from_numpy(numeric).to(device),\n )\n positive_vectors, negative_vectors = candidate_vectors(\n model,\n positive_items[begin:end],\n negatives[begin:end],\n )\n positive_logits = (query_vectors * positive_vectors).sum(dim=1)\n negative_logits = torch.einsum(\n \"bd,bkd->bk\", query_vectors, negative_vectors\n )\n positive_loss = F.softplus(-positive_logits)\n negative_loss = corrected_negative_loss(\n negative_logits, negative_weights[begin:end], config\n )\n selected_weights = torch.from_numpy(\n sample_weights[begin:end].astype(np.float32, copy=False)\n ).to(device)\n loss_sum = (\n selected_weights * (positive_loss + negative_loss) / 2.0\n ).sum()\n if not bool(torch.isfinite(loss_sum)):\n raise RuntimeError(\"dense next-item training produced non-finite loss\")\n if backward:\n (loss_sum / total_weight).backward()\n if sequence_input.grad is None:\n raise RuntimeError(\"sequence microbatch did not produce a gradient\")\n sequence_gradient[begin:end] = sequence_input.grad\n statistics[\"loss_sum\"] += float(loss_sum.detach().cpu())\n statistics[\"positive_sum\"] += float(positive_logits.detach().sum().cpu())\n statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n statistics[\"pair_correct\"] += float(\n (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n )\n statistics[\"weight_sum\"] += float(selected_weights.sum().detach().cpu())\n return statistics, sequence_gradient\n\n\ndef release_accelerator_cache(device: torch.device) -> None:\n if device.type == \"mps\":\n torch.mps.empty_cache()\n\n\ndef run_training_epoch(\n model: base.SequentialTwoTower,\n optimizer: torch.optim.Optimizer,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n target_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n generator: torch.Generator,\n device: torch.device,\n epoch: int,\n) -> dict[str, float]:\n model.train()\n train_users = np.flatnonzero(target_counts > 0)\n sorted_users = train_users[\n np.argsort(target_counts[train_users], kind=\"stable\")\n ]\n groups = [\n sorted_users[start : start + config.user_batch_size]\n for start in range(0, len(sorted_users), config.user_batch_size)\n ]\n group_order = torch.randperm(len(groups), generator=generator).numpy()\n negative_count = config.uniform_negatives + config.inbatch_negatives\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n total_positive = 0.0\n total_negative = 0.0\n total_pair_correct = 0.0\n batches = 0\n started = time.monotonic()\n\n for group_number in group_order:\n users = groups[int(group_number)]\n group_counts = target_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_counts.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_counts,\n np.zeros(len(users), dtype=np.int64),\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0).detach()\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n optimizer.zero_grad(set_to_none=True)\n statistics, sequence_gradient = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=True,\n )\n if sequence_gradient is None:\n raise RuntimeError(\"training block omitted its sequence gradient\")\n sequence_vectors.backward(sequence_gradient)\n nn.utils.clip_grad_norm_(model.parameters(), config.gradient_clip)\n optimizer.step()\n\n count = len(positive_items)\n total_loss += statistics[\"loss_sum\"]\n total_targets += count\n total_weight += statistics[\"weight_sum\"]\n total_positive += statistics[\"positive_sum\"]\n total_negative += statistics[\"negative_sum\"]\n total_pair_correct += statistics[\"pair_correct\"]\n batches += 1\n if batches % 25 == 0:\n release_accelerator_cache(device)\n if batches % 250 == 0:\n batch_bce = statistics[\"loss_sum\"] / max(\n statistics[\"weight_sum\"], 1.0e-12\n )\n running_bce = total_loss / max(total_weight, 1.0e-12)\n log(\n f\"epoch={epoch}/{config.epochs} batches={batches:,} \"\n f\"targets={total_targets:,} \"\n f\"batch_bce={batch_bce:.6f} \"\n f\"running_bce={running_bce:.6f} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n return {\n \"epoch\": float(epoch),\n \"loss\": total_loss / max(total_weight, 1.0e-12),\n \"positive_logit\": total_positive / max(total_targets, 1),\n \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n \"pair_accuracy\": total_pair_correct\n / max(total_targets * negative_count, 1),\n \"targets\": float(total_targets),\n \"target_weight_sum\": total_weight,\n \"batches\": float(batches),\n \"seconds\": time.monotonic() - started,\n }\n\n\[email protected]_grad()\ndef evaluate_validation_bce(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n training_counts: np.ndarray,\n validation_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n device: torch.device,\n) -> dict[str, float]:\n \"\"\"Measure deterministic BCE on each user's chronological target suffix.\"\"\"\n model.eval()\n totals = training_counts + validation_counts\n users_with_validation = np.flatnonzero(validation_counts > 0)\n ordered = users_with_validation[\n np.argsort(totals[users_with_validation], kind=\"stable\")\n ]\n groups = [\n ordered[start : start + config.user_batch_size]\n for start in range(0, len(ordered), config.user_batch_size)\n ]\n generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 20_000)\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n started = time.monotonic()\n\n for group_number, users in enumerate(groups, start=1):\n group_totals = totals[users]\n group_starts = training_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_totals.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_totals,\n group_starts,\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0)\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n statistics, _ = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=False,\n )\n total_loss += statistics[\"loss_sum\"]\n total_targets += len(positive_items)\n total_weight += statistics[\"weight_sum\"]\n if group_number % 25 == 0:\n release_accelerator_cache(device)\n return {\n \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n \"validation_targets\": float(total_targets),\n \"validation_target_weight_sum\": total_weight,\n \"validation_seconds\": time.monotonic() - started,\n }\n\n\ndef snapshot_model(model: nn.Module) -> dict[str, torch.Tensor]:\n return {\n name: value.detach().cpu().clone()\n for name, value in model.state_dict().items()\n }\n\n\ndef save_epoch_checkpoint(\n checkpoint_dir: Path,\n epoch: int,\n model_state: dict[str, torch.Tensor],\n config: Config,\n diagnostics: dict[str, float],\n optimizer: torch.optim.Optimizer,\n generator: torch.Generator,\n) -> Path:\n checkpoint_dir.mkdir(parents=True, exist_ok=True)\n path = checkpoint_dir / f\"epoch-{epoch:02d}.pt\"\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": model_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n path,\n )\n log(f\"saved epoch checkpoint: {path}\")\n return path\n\n\ndef train_model(\n public: Path,\n stage: str,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n checkpoint_dir: Path | None,\n resume_checkpoint: Path | None,\n) -> tuple[base.SequentialTwoTower, list[dict[str, float]]]:\n partition = chronological_target_partition(\n prepared,\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n training_counts = partition.training_counts\n validation_counts = partition.validation_counts\n log(\n f\"chronological BCE window: train={int(training_counts.sum()):,} \"\n f\"validation={int(validation_counts.sum()):,} \"\n f\"selected={partition.selected_count:,} \"\n f\"discarded_old_targets={int(partition.window_starts.sum()):,} \"\n f\"all={prepared.target_count:,}\"\n )\n model = base.SequentialTwoTower(prepared.item_features, model_config(config)).to(\n device\n )\n optimizer = torch.optim.AdamW(\n model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay\n )\n",
"stderr": "",
"aggregated_output": "7:16 held-out validation targets; passing zero for either cap restores the\n14:and 24 uniform plus eight propensity-corrected unique in-batch negatives. Every\n20:import argparse\n56: uniform_negatives: int = 24\n57: inbatch_negatives: int = 8\n61: negative_filter_scope: str = \"full-training-history\"\n67: validation_fraction: float = 0.10\n69: max_validation_targets_per_user: int = 16\n132: validation_counts: np.ndarray\n137: return int(self.training_counts.sum() + self.validation_counts.sum())\n152: return base.TowerConfig(\n165: uniform_negatives=config.uniform_negatives,\n166: inbatch_negatives=config.inbatch_negatives,\n346: \"popularity_rank\"\n354: if stage == \"validation\":\n357: candidate_name = \"validation_eligible_candidates.npz\"\n358: state_name = \"validation_preference_state.parquet\"\n477: \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n518: \"validation_eligible_candidates.npz\"\n519: if stage == \"validation\"\n603: f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n609:def invalid_full_history_negatives(\n610: negatives: np.ndarray,\n616: \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n617: byte_columns = negatives >> 3\n618: bit_columns = negatives & 7\n621: invalid |= negatives == targets[:, None]\n622: invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n623: for column in range(1, negatives.shape[1]):\n625: negatives[:, column, None] == negatives[:, :column]\n630:def draw_full_history_filtered_negatives(\n641: \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n647: (batch_size, config.uniform_negatives),\n653: if config.inbatch_negatives:\n656: config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n672: if corrected_count < config.inbatch_negatives:\n675: (batch_size, config.inbatch_negatives - corrected_count),\n681: negatives = torch.cat([uniform, inbatch], dim=1).numpy()\n682: correction = np.ones_like(negatives, dtype=np.float32)\n685: :, config.uniform_negatives : config.uniform_negatives + corrected_count\n688: invalid = invalid_full_history_negatives(\n689: negatives,\n697: torch.from_numpy(negatives).to(device),\n706: negatives[invalid] = replacements\n709: invalid = invalid_full_history_negatives(\n710: negatives,\n717: raise RuntimeError(\"full-history negative resampling failed\")\n719: torch.from_numpy(negatives).to(device),\n724:def draw_training_negatives(\n736: if config.negative_filter_scope == \"full-training-history\":\n738: raise RuntimeError(\"full-history negative filter was not built\")\n739: return draw_full_history_filtered_negatives(\n750: return base.draw_filtered_mixed_negatives(\n755: config.uniform_negatives,\n756: config.inbatch_negatives,\n816: negatives: torch.Tensor,\n818: all_items = torch.cat([positives, negatives.reshape(-1)])\n822: negative_vectors = encoded[inverse[len(positives) :]].reshape(\n823: len(positives), negatives.shape[1], -1\n825: return positive_vectors, negative_vectors\n845:def corrected_negative_loss(\n851: total = config.uniform_negatives + config.inbatch_negatives\n853: if config.uniform_negatives:\n854: result += config.uniform_negatives * raw[\n855: :, : config.uniform_negatives\n857: if config.inbatch_negatives:\n858: selected = slice(config.uniform_negatives, total)\n860: result += config.inbatch_negatives * (\n869: validation_counts: np.ndarray,\n873: training_region_ends = total_targets - validation_counts\n878: or np.any(validation_counts < 0)\n880: raise RuntimeError(\"chronological target partition produced negative counts\")\n882: window_starts + training_counts + validation_counts, total_targets\n890: validation_fraction: float,\n892: max_validation_targets_per_user: int,\n896: validation = np.floor(totals * validation_fraction).astype(np.int64)\n897: validation[(totals >= 2) & (validation == 0)] = 1\n898: if max_validation_targets_per_user:\n899: validation = np.minimum(validation, max_validation_targets_per_user)\n901: history_training = totals - validation\n905: window_starts = retained_target_window_starts(totals, validation, training)\n909: validation_counts=validation,\n918: prefixes = (\"train\",) if stage == \"validation\" else (\"train\", \"val\")\n981: base.TRAIN_END if stage == \"validation\" else base.VALIDATION_END\n991: first_validation_target = last_training_target + 1\n993: int(prepared.sequence_timestamps[first_validation_target])\n994: if first_validation_target < end\n1119: negatives: torch.Tensor,\n1120: negative_weights: torch.Tensor,\n1134: \"negative_sum\": 0.0,\n1162: positive_vectors, negative_vectors = candidate_vectors(\n1165: negatives[begin:end],\n1168: negative_logits = torch.einsum(\n1169: \"bd,bkd->bk\", query_vectors, negative_vectors\n1172: negative_loss = corrected_negative_loss(\n1173: negative_logits, negative_weights[begin:end], config\n1179: selected_weights * (positive_loss + negative_loss) / 2.0\n1190: statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n1192: (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n1227: negative_count = config.uniform_negatives + config.inbatch_negatives\n1232: total_negative = 0.0\n1293: negatives, negative_weights = draw_training_negatives(\n1314: negatives,\n1315: negative_weights,\n1331: total_negative += statistics[\"negative_sum\"]\n1353: \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n1355: / max(total_targets * negative_count, 1),\n1364:def evaluate_validation_bce(\n1371: validation_counts: np.ndarray,\n1378: totals = training_counts + validation_counts\n1379: users_with_validation = np.flatnonzero(validation_counts > 0)\n1380: ordered = users_with_validation[\n1381: np.argsort(totals[users_with_validation], kind=\"stable\")\n1449: negatives, negative_weights = draw_training_negatives(\n1469: negatives,\n1470: negative_weights,\n1481: \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n1482: \"validation_targets\": float(total_targets),\n1483: \"validation_target_weight_sum\": total_weight,\n1484: \"validation_seconds\": time.monotonic() - started,\n1521:def train_model(\n1532: config.validation_fraction,\n1534: config.max_validation_targets_per_user,\n1537: validation_counts = partition.validation_counts\n1540: f\"validation={int(validation_counts.sum()):,} \"\n1567: if config.negative_filter_scope == \"full-training-history\"\n1601: \"negative_filter_scope\", \"last-100\"\n1619: stored_config.get(\"validation_fraction\", 0.10),\n1621: stored_config.get(\"max_validation_targets_per_user\", 0),\n1624: config.validation_fraction,\n1626: config.max_validation_targets_per_user,\n1629: stored_filter_scope != config.negative_filter_scope\n1657: prior.pop(\"validation_loss\", None)\n1660: f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n1664: \"recomputing the resume checkpoint's validation BCE\"\n1666: if \"validation_loss\" not in prior:\n1668: evaluate_validation_bce(\n1675: validation_counts,\n1682: best_loss = float(prior[\"validation_loss\"])\n1686: f\"resumed validation-BCE training: epoch={start_epoch} \"\n1705: validation = evaluate_validation_bce(\n1712: validation_counts,\n1717: diagnostics.update(validation)\n1731: diagnostics[\"validation_loss\"]\n1735: best_loss = diagnostics[\"validation_loss\"]\n1755: f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n1758: f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n1760: f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n1766: f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n1775: f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n1861:def rank_candidates(\n1867: ranking_batch_size: int,\n1882: for start in range(0, len(eligibility.uids), ranking_batch_size):\n1883: end = min(start + ranking_batch_size, len(eligibility.uids))\n1896: (uid, int(prepared.item_ids[column]), rank)\n1897: for rank, column in enumerate(columns, start=1)\n1900: log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n1909: return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n1912:def write_ranking(frame: pd.DataFrame, output: Path) -> None:\n1923: parser = argparse.ArgumentParser(description=__doc__)\n input_size=config.hidden_dim,\n hidden_size=dim,\n num_layers=1,\n batch_first=True,\n )\n self.user_tower = nn.Sequential(\n nn.Linear(dim * 3 + 6, config.user_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_1, config.user_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_2, dim),\n )\n self.candidate_embedding = nn.Embedding(n_items, dim)\n self.register_buffer(\n \"item_features\", torch.from_numpy(item_features), persistent=True\n )\n self.item_tower = nn.Sequential(\n nn.Linear(dim + n_item_features, config.item_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_1, config.item_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_2, dim),\n )\n nn.init.normal_(self.context_embedding.weight, std=0.02)\n nn.init.normal_(self.candidate_embedding.weight, std=0.02)\n with torch.no_grad():\n self.context_embedding.weight[n_items].zero_()\n\n def _mean_context_channel(self, items: torch.Tensor) -> torch.Tensor:\n mask = items.ne(self.n_items)\n vectors = self.context_embedding(items)\n return (vectors * mask.unsqueeze(-1)).sum(dim=1) / mask.sum(\n dim=1, keepdim=True\n ).clamp_min(1)\n\n def encode_items(self, items: torch.Tensor) -> torch.Tensor:\n inputs = torch.cat(\n [self.candidate_embedding(items), self.item_features[items]], dim=1\n )\n return self.item_tower(inputs)\n\n\ndef invalid_negative_mask(\n negatives: torch.Tensor,\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n) -> torch.Tensor:\n \"\"\"Identify targets, known positives, active likes, and row duplicates.\"\"\"\n invalid = negatives.eq(targets[:, None])\n invalid |= negatives[:, :, None].eq(context_items[:, None, :]).any(dim=2)\n invalid |= negatives[:, :, None].eq(liked_items[:, None, :]).any(dim=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= negatives[:, column, None].eq(\n negatives[:, :column]\n ).any(dim=1)\n return invalid\n\n\ndef draw_filtered_mixed_negatives(\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n n_items: int,\n uniform_count: int,\n inbatch_count: int,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw uniform and unique-target in-batch negatives.\"\"\"\n batch_size = len(targets)\n uniform = torch.randint(\n n_items,\n (batch_size, uniform_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long, device=\"cpu\")\n corrected_count = 0\n if inbatch_count:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(inbatch_count, max(len(unique_targets) - 1, 0))\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n )\n priorities.masked_fill_(\n unique_targets[None, :].eq(targets.detach().cpu()[:, None]),\n -1.0,\n )\n columns = torch.topk(priorities, k=corrected_count, dim=1).indices\n inbatch = unique_targets[columns]\n if corrected_count < inbatch_count:\n filler = torch.randint(\n n_items,\n (batch_size, inbatch_count - corrected_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.cat([inbatch, filler], dim=1)\n\n negatives_cpu = torch.cat([uniform, inbatch], dim=1)\n correction_cpu = torch.ones_like(negatives_cpu, dtype=torch.float32)\n if corrected_count:\n correction_cpu[\n :, uniform_count : uniform_count + corrected_count\n ] = item_correction[inbatch[:, :corrected_count]]\n negatives = negatives_cpu.to(device)\n correction = correction_cpu.to(device)\n for _ in range(32):\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if not bool(invalid.any()):\n return negatives, correction\n replacements = torch.randint(\n n_items,\n (int(invalid.sum().detach().cpu()),),\n generator=generator,\n device=\"cpu\",\n ).to(device)\n negatives[invalid] = replacements\n correction[invalid] = 1.0\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if bool(invalid.any()):\n raise RuntimeError(\"mixed negative resampling failed false-negative filter\")\n return negatives, correction\n field: artifact[field].copy()\n for field in SequenceData.__dataclass_fields__\n }\n prepared = SequenceData(**values)\n log(\n f\"loaded full-sequence cache: tokens={len(prepared.sequence_items):,} \"\n f\"targets={prepared.target_count:,}\"\n )\n return prepared\n\n\ndef load_or_prepare(\n public: Path,\n stage: str,\n config: Config,\n cache: Path | None,\n) -> tuple[SequenceData, CandidateSet]:\n candidate_name = (\n \"validation_eligible_candidates.npz\"\n if stage == \"validation\"\n else \"test_eligible_candidates.npz\"\n )\n eligibility = CandidateSet.read(public / \"data\" / candidate_name)\n if cache is not None and cache.is_file():\n prepared = load_cache(cache, stage, config)\n if not np.array_equal(prepared.item_ids, eligibility.item_ids):\n raise RuntimeError(\"cached catalog differs from candidate artifact\")\n return prepared, eligibility\n prepared, built_eligibility = prepare_data(public, stage, config)\n if cache is not None:\n save_cache(cache, prepared, stage, config)\n return prepared, built_eligibility\n\n\ndef build_item_correction(\n prepared: SequenceData,\n config: Config,\n target_starts: np.ndarray,\n target_counts: np.ndarray,\n) -> torch.Tensor:\n \"\"\"Estimate in-batch propensities from the selected training targets.\"\"\"\n counts = np.zeros(len(prepared.item_ids), dtype=np.float64)\n for user_row, count in enumerate(target_counts):\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n target_start = int(target_starts[user_row])\n counts += np.bincount(\n prepared.sequence_items[\n offset\n + target_start\n + 1 : offset\n + target_start\n + int(count)\n + 1\n ].astype(np.int64),\n minlength=len(prepared.item_ids),\n )\n total = float(counts.sum())\n probabilities = (counts + 1.0) / (total + len(counts))\n raw = np.power(\n (1.0 / len(counts)) / probabilities,\n config.inbatch_correction_power,\n )\n normalization = float(np.sum(counts * raw) / max(total, 1.0))\n correction = np.clip(\n raw / max(normalization, 1.0e-12),\n config.inbatch_correction_min,\n config.inbatch_correction_max,\n ).astype(np.float32)\n mean_on_targets = float(np.sum(counts * correction) / max(total, 1.0))\n log(\n f\"inbatch correction: min={correction.min():.3f} \"\n f\"mean_on_targets={mean_on_targets:.3f} max={correction.max():.3f}\"\n )\n return torch.from_numpy(correction)\n\n\ndef build_known_positive_filter(\n prepared: SequenceData, training_counts: np.ndarray\n) -> np.ndarray:\n \"\"\"Pack each user's complete public-training positive-item set.\"\"\"\n width = (len(prepared.item_ids) + 7) // 8\n packed = np.zeros((len(prepared.user_ids), width), dtype=np.uint8)\n retained = 0\n for user_row, target_count in enumerate(training_counts):\n if not target_count:\n continue\n offset = int(prepared.offsets[user_row])\n # T adjacent targets consume sequence positions 0 through T. The\n # chronological BCE suffix starts at T + 1 and is intentionally absent.\n items = np.unique(\n prepared.sequence_items[\n offset : offset + int(target_count) + 1\n ].astype(np.int64)\n )\n byte_columns = items >> 3\n bit_values = np.left_shift(\n np.uint8(1), (items & 7).astype(np.uint8)\n )\n np.bitwise_or.at(packed[user_row], byte_columns, bit_values)\n retained += len(items)\n log(\n f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n f\"unique_user_items={retained:,} bytes={packed.nbytes:,}\"\n )\n return packed\n\n\ndef invalid_full_history_negatives(\n negatives: np.ndarray,\n targets: np.ndarray,\n user_rows: np.ndarray,\n known_positive_packed: np.ndarray,\n liked_items: np.ndarray,\n) -> np.ndarray:\n \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n byte_columns = negatives >> 3\n bit_columns = negatives & 7\n known = known_positive_packed[user_rows[:, None], byte_columns]\n invalid = ((known >> bit_columns) & 1).astype(bool)\n invalid |= negatives == targets[:, None]\n invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= (\n negatives[:, column, None] == negatives[:, :column]\n ).any(axis=1)\n return invalid\n\n\ndef draw_full_history_filtered_negatives(\n targets: torch.Tensor,\n user_rows: np.ndarray,\n liked_items: torch.Tensor,\n known_positive_packed: np.ndarray,\n n_items: int,\n config: Config,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n targets_cpu = targets.detach().cpu().numpy().astype(np.int64, copy=False)\n liked_cpu = liked_items.detach().cpu().numpy().astype(np.int64, copy=False)\n batch_size = len(targets_cpu)\n uniform = torch.randint(\n n_items,\n (batch_size, config.uniform_negatives),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long)\n corrected_count = 0\n if config.inbatch_negatives:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(\n config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n )\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n )\n priorities.masked_fill_(\n unique_targets[None, :].eq(\n torch.from_numpy(targets_cpu)[:, None]\n ),\n -1.0,\n )\n columns = torch.topk(\n priorities, k=corrected_count, dim=1\n ).indices\n inbatch = unique_targets[columns]\n if corrected_count < config.inbatch_negatives:\n filler = torch.randint(\n n_items,\n (batch_size, config.inbatch_negatives - corrected_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.cat([inbatch, filler], dim=1)\n\n negatives = torch.cat([uniform, inbatch], dim=1).numpy()\n correction = np.ones_like(negatives, dtype=np.float32)\n if corrected_count:\n correction[\n :, config.uniform_negatives : config.uniform_negatives + corrected_count\n ] = item_correction[inbatch[:, :corrected_count]].numpy()\n for _ in range(32):\n invalid = invalid_full_history_negatives(\n negatives,\n targets_cpu,\n user_rows,\n known_positive_packed,\n liked_cpu,\n )\n if not np.any(invalid):\n return (\n torch.from_numpy(negatives).to(device),\n torch.from_numpy(correction).to(device),\n )\n replacements = torch.randint(\n n_items,\n (int(invalid.sum()),),\n generator=generator,\n device=\"cpu\",\n ).numpy()\n negatives[invalid] = replacements\n # Rejected in-batch items become ordinary uniform draws.\n correction[invalid] = 1.0\n invalid = invalid_full_history_negatives(\n negatives,\n targets_cpu,\n user_rows,\n known_positive_packed,\n liked_cpu,\n )\n if np.any(invalid):\n raise RuntimeError(\"full-history negative resampling failed\")\n return (\n torch.from_numpy(negatives).to(device),\n torch.from_numpy(correction).to(device),\n )\n\n\ndef draw_training_negatives(\n positive_items: torch.Tensor,\n known_history: torch.Tensor,\n liked_items: torch.Tensor,\n user_rows: np.ndarray,\n known_positive_packed: np.ndarray | None,\n prepared: SequenceData,\n config: Config,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n if config.negative_filter_scope == \"full-training-history\":\n if known_positive_packed is None:\n raise RuntimeError(\"full-history negative filter was not built\")\n return draw_full_history_filtered_negatives(\n positive_items,\n user_rows,\n liked_items,\n known_positive_packed,\n len(prepared.item_ids),\n config,\n item_correction,\n generator,\n device,\n )\n return base.draw_filtered_mixed_negatives(\n positive_items,\n known_history,\n liked_items,\n len(prepared.item_ids),\n config.uniform_negatives,\n config.inbatch_negatives,\n item_correction,\n generator,\n device,\n )\n\n\ndef event_sequence(\n model: base.SequentialTwoTower,\n items: torch.Tensor,\n features: torch.Tensor,\n hidden: torch.Tensor,\n) -> tuple[torch.Tensor, torch.Tensor]:\n inputs = torch.cat([model.context_embedding(items), features], dim=-1)\n projected = model.event_projection(inputs)\n return model.sequence_encoder(projected, hidden)\n\n\ndef pool_explicit_states(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n state_ids: np.ndarray,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:\n unique_states, inverse = np.unique(state_ids, return_inverse=True)\n liked_items = torch.from_numpy(\n prepared.liked_states[unique_states].astype(np.int64)\n ).to(device)\n disliked_items = torch.from_numpy(\n prepared.disliked_states[unique_states].astype(np.int64)\n ).to(device)\n liked_vectors = model._mean_context_channel(liked_items)\n disliked_vectors = model._mean_context_channel(disliked_items)\n inverse_rows = torch.from_numpy(inverse.astype(np.int64)).to(device)\n counts = prepared.state_counts[state_ids]\n return (\n liked_vectors[inverse_rows],\n disliked_vectors[inverse_rows],\n torch.from_numpy(counts.astype(np.float32)).to(device),\n )\n\n\ndef user_queries(\n model: base.SequentialTwoTower,\n sequence_vectors: torch.Tensor,\n liked_vectors: torch.Tensor,\n disliked_vectors: torch.Tensor,\n user_features: torch.Tensor,\n) -> torch.Tensor:\n return model.user_tower(\n torch.cat(\n [sequence_vectors, liked_vectors, disliked_vectors, user_features],\n dim=1,\n )\n )\n\n\ndef candidate_vectors(\n model: base.SequentialTwoTower,\n positives: torch.Tensor,\n negatives: torch.Tensor,\n) -> tuple[torch.Tensor, torch.Tensor]:\n all_items = torch.cat([positives, negatives.reshape(-1)])\n unique_items, inverse = torch.unique(all_items, return_inverse=True)\n encoded = model.encode_items(unique_items)\n positive_vectors = encoded[inverse[: len(positives)]]\n negative_vectors = encoded[inverse[len(positives) :]].reshape(\n len(positives), negatives.shape[1], -1\n )\n return positive_vectors, negative_vectors\n\n\ndef history_windows(\n sequence: np.ndarray,\n start: int,\n count: int,\n width: int,\n padding_item: int,\n) -> np.ndarray:\n base_start = max(0, start - width + 1)\n segment = sequence[base_start : start + count]\n padded = np.concatenate(\n [np.full(width - 1, padding_item, dtype=np.uint16), segment]\n )\n windows = np.lib.stride_tricks.sliding_window_view(padded, width)\n offset = start - base_start\n return windows[offset : offset + count].copy()\n\n\ndef corrected_negative_loss(\n logits: torch.Tensor,\n correction: torch.Tensor,\n config: Config,\n) -> torch.Tensor:\n raw = F.softplus(logits)\n total = config.uniform_negatives + config.inbatch_negatives\n result = torch.zeros(len(logits), device=logits.device)\n if config.uniform_negatives:\n result += config.uniform_negatives * raw[\n :, : config.uniform_negatives\n ].mean(dim=1)\n if config.inbatch_negatives:\n selected = slice(config.uniform_negatives, total)\n weights = correction[:, selected]\n result += config.inbatch_negatives * (\n (raw[:, selected] * weights).sum(dim=1)\n / weights.sum(dim=1).clamp_min(1.0e-6)\n )\n return result / total\n\n\ndef retained_target_window_starts(\n total_targets: np.ndarray,\n validation_counts: np.ndarray,\n training_counts: np.ndarray,\n) -> np.ndarray:\n \"\"\"Return the discarded prefix before the retained chronological suffix.\"\"\"\n training_region_ends = total_targets - validation_counts\n window_starts = training_region_ends - training_counts\n if (\n np.any(window_starts < 0)\n or np.any(training_counts < 0)\n or np.any(validation_counts < 0)\n ):\n raise RuntimeError(\"chronological target partition produced negative counts\")\n if not np.array_equal(\n window_starts + training_counts + validation_counts, total_targets\n ):\n raise RuntimeError(\"chronological target window is not a final contiguous suffix\")\n return window_starts\n\n\ndef chronological_target_partition(\n prepared: SequenceData,\n validation_fraction: float,\n max_train_targets_per_user: int,\n max_validation_targets_per_user: int,\n) -> TargetPartition:\n \"\"\"Select a recent contiguous window with a final out-of-time BCE suffix.\"\"\"\n totals = np.maximum(prepared.lengths - 1, 0).astype(np.int64)\n validation = np.floor(totals * validation_fraction).astype(np.int64)\n validation[(totals >= 2) & (validation == 0)] = 1\n if max_validation_targets_per_user:\n validation = np.minimum(validation, max_validation_targets_per_user)\n\n history_training = totals - validation\n training = history_training.copy()\n if max_train_targets_per_user:\n training = np.minimum(training, max_train_targets_per_user)\n window_starts = retained_target_window_starts(totals, validation, training)\n return TargetPartition(\n window_starts=window_starts,\n training_counts=training,\n validation_counts=validation,\n history_training_counts=history_training,\n )\n\n\ndef load_organic_like_outcomes(\n public: Path, stage: str, item_ids: np.ndarray\n) -> dict[int, dict[int, np.ndarray]]:\n \"\"\"Load mapped organic-like timestamps from public fitting history.\"\"\"\n prefixes = (\"train\",) if stage == \"validation\" else (\"train\", \"val\")\n frames = [\n pd.read_parquet(\n public / \"data\" / f\"{prefix}_likes.parquet\",\n columns=[\"uid\", \"timestamp\", \"item_id\", \"is_organic\"],\n )\n for prefix in prefixes\n ]\n likes = pd.concat(frames, ignore_index=True)\n likes = likes[likes.is_organic == 1].copy()\n mapped = pd.Index(item_ids).get_indexer(\n likes.item_id.to_numpy(dtype=np.int64)\n )\n likes = likes.loc[mapped >= 0, [\"uid\", \"timestamp\"]].copy()\n likes[\"item\"] = mapped[mapped >= 0].astype(np.int32, copy=False)\n likes = likes.sort_values([\"uid\", \"item\", \"timestamp\"], kind=\"stable\")\n nested: dict[int, dict[int, list[int]]] = {}\n for raw_uid, raw_timestamp, raw_item in likes.itertuples(\n index=False, name=None\n ):\n nested.setdefault(int(raw_uid), {}).setdefault(int(raw_item), []).append(\n int(raw_timestamp)\n )\n result = {\n uid: {\n item: np.asarray(timestamps, dtype=np.int64)\n for item, timestamps in items.items()\n }\n for uid, items in nested.items()\n }\n log(\n f\"loaded organic-like outcomes: rows={len(likes):,} \"\n f\"users={len(result):,}\"\n )\n return result\n\n\ndef build_target_weights(\n public: Path,\n stage: str,\n prepared: SequenceData,\n training_counts: np.ndarray,\n config: Config,\n) -> np.ndarray:\n \"\"\"Build nonzero per-token weights without dropping adjacent targets.\"\"\"\n if config.target_weighting == \"uniform\":\n weights = np.ones(len(prepared.sequence_items), dtype=np.float32)\n else:\n weights = np.full(\n len(prepared.sequence_items),\n config.ordinary_target_weight,\n dtype=np.float32,\n )\n organic_strong = (\n (prepared.event_features[:, 0] > 0.5)\n & (prepared.event_features[:, 1] >= 0.8)\n )\n weights[organic_strong] = config.organic_strong_target_weight\n if config.target_weighting == \"discovery-graded\":\n like_outcomes = load_organic_like_outcomes(\n public, stage, prepared.item_ids\n )\n history_end = (\n base.TRAIN_END if stage == \"validation\" else base.VALIDATION_END\n )\n horizon = config.discovery_horizon_days * base.DAY\n grade_three = 0\n for user_row, uid in enumerate(prepared.user_ids):\n start = int(prepared.offsets[user_row])\n end = int(prepared.offsets[user_row + 1])\n if end - start < 2:\n continue\n last_training_target = start + int(training_counts[user_row])\n first_validation_target = last_training_target + 1\n split_timestamp = (\n int(prepared.sequence_timestamps[first_validation_target])\n if first_validation_target < end\n else history_end\n )\n user_likes = like_outcomes.get(int(uid), {})\n seen = {int(prepared.sequence_items[start])}\n for position in range(start + 1, end):\n item = int(prepared.sequence_items[position])\n novel = item not in seen\n seen.add(item)\n if not novel:\n # Repeats remain training examples, but they cannot be\n # eligible discovery outcomes at the corresponding\n # cutoff and therefore keep only the ordinary weight.\n weights[position] = config.ordinary_target_weight\n continue\n timestamps = user_likes.get(item)\n if timestamps is None:\n continue\n timestamp = int(prepared.sequence_timestamps[position])\n upper = min(\n timestamp + horizon,\n split_timestamp\n if position <= last_training_target\n else history_end,\n )\n outcome = int(np.searchsorted(timestamps, timestamp, side=\"left\"))\n if outcome < len(timestamps) and int(timestamps[outcome]) < upper:\n weights[position] = config.organic_like_target_weight\n grade_three += 1\n log(\n f\"grade-3 first-encounter targets={grade_three:,} \"\n f\"horizon_days={config.discovery_horizon_days}\"\n )\n log(\n f\"target weighting={config.target_weighting} \"\n f\"min={weights.min():.3f} mean={weights.mean():.3f} \"\n f\"max={weights.max():.3f}\"\n )\n return weights\n\n\ndef build_dense_block(\n prepared: SequenceData,\n target_weights: np.ndarray,\n users: np.ndarray,\n window_starts: np.ndarray,\n process_counts: np.ndarray,\n score_starts: np.ndarray,\n start: int,\n steps: int,\n config: Config,\n) -> tuple[np.ndarray, ...]:\n \"\"\"Build one causal sequence block and mark the target rows to score.\"\"\"\n batch_size = len(users)\n padding_item = len(prepared.item_ids)\n input_items = np.full((batch_size, steps), padding_item, dtype=np.uint16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n activity_features = np.zeros((batch_size, steps, 4), dtype=np.float16)\n state_ids = np.zeros((batch_size, steps), dtype=np.uint32)\n targets = np.zeros((batch_size, steps), dtype=np.uint16)\n weights = np.ones((batch_size, steps), dtype=np.float32)\n scored = np.zeros((batch_size, steps), dtype=bool)\n histories = np.full(\n (batch_size, steps, config.bptt_steps),\n padding_item,\n dtype=np.uint16,\n )\n processed = np.zeros(batch_size, dtype=np.int32)\n\n lengths = prepared.lengths\n for row, user_row in enumerate(users):\n count = min(steps, max(int(process_counts[row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n window_start = int(window_starts[row])\n absolute_start = window_start + start\n positions = slice(\n offset + absolute_start,\n offset + absolute_start + count,\n )\n target_positions = slice(\n offset + absolute_start + 1,\n offset + absolute_start + count + 1,\n )\n input_items[row, :count] = prepared.sequence_items[positions]\n event_features[row, :count] = prepared.event_features[positions]\n activity_features[row, :count] = prepared.activity_features[positions]\n state_ids[row, :count] = prepared.state_ids[positions]\n targets[row, :count] = prepared.sequence_items[target_positions]\n weights[row, :count] = target_weights[target_positions]\n score_from = min(count, max(int(score_starts[row]) - start, 0))\n scored[row, score_from:count] = True\n processed[row] = count\n user_sequence = prepared.sequence_items[\n offset : offset + int(lengths[user_row])\n ]\n histories[row, :count] = history_windows(\n user_sequence,\n absolute_start,\n count,\n config.bptt_steps,\n padding_item,\n )\n return (\n input_items,\n event_features,\n activity_features,\n state_ids,\n targets,\n scored,\n histories,\n processed,\n weights,\n )\n\n\ndef score_target_microbatches(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n sequence_vectors: torch.Tensor,\n flat_states: np.ndarray,\n flat_activity: np.ndarray,\n positive_items: torch.Tensor,\n negatives: torch.Tensor,\n negative_weights: torch.Tensor,\n sample_weights: np.ndarray,\n device: torch.device,\n backward: bool,\n) -> tuple[dict[str, float], torch.Tensor | None]:\n \"\"\"Compute one logical block's exact BCE in bounded-memory slices.\"\"\"\n target_count = len(positive_items)\n total_weight = float(sample_weights.sum())\n if total_weight <= 0:\n raise RuntimeError(\"target block has no positive sample weight\")\n sequence_gradient = torch.zeros_like(sequence_vectors) if backward else None\n statistics = {\n \"loss_sum\": 0.0,\n \"positive_sum\": 0.0,\n \"negative_sum\": 0.0,\n \"pair_correct\": 0.0,\n \"weight_sum\": 0.0,\n }\n for begin in range(0, target_count, config.loss_microbatch_size):\n end = min(begin + config.loss_microbatch_size, target_count)\n selected_states = flat_states[begin:end]\n if backward:\n sequence_input = (\n sequence_vectors[begin:end].detach().requires_grad_(True)\n )\n else:\n sequence_input = sequence_vectors[begin:end]\n liked_vectors, disliked_vectors, _ = pool_explicit_states(\n model, prepared, selected_states, device\n )\n counts = prepared.state_counts[selected_states]\n numeric = np.zeros((end - begin, 6), dtype=np.float32)\n numeric[:, :4] = flat_activity[begin:end]\n numeric[:, 4] = np.log1p(counts[:, 0]) / 6.0\n numeric[:, 5] = np.log1p(counts[:, 1]) / 4.0\n query_vectors = user_queries(\n model,\n sequence_input,\n liked_vectors,\n disliked_vectors,\n torch.from_numpy(numeric).to(device),\n )\n positive_vectors, negative_vectors = candidate_vectors(\n model,\n positive_items[begin:end],\n negatives[begin:end],\n )\n positive_logits = (query_vectors * positive_vectors).sum(dim=1)\n negative_logits = torch.einsum(\n \"bd,bkd->bk\", query_vectors, negative_vectors\n )\n positive_loss = F.softplus(-positive_logits)\n negative_loss = corrected_negative_loss(\n negative_logits, negative_weights[begin:end], config\n )\n selected_weights = torch.from_numpy(\n sample_weights[begin:end].astype(np.float32, copy=False)\n ).to(device)\n loss_sum = (\n selected_weights * (positive_loss + negative_loss) / 2.0\n ).sum()\n if not bool(torch.isfinite(loss_sum)):\n raise RuntimeError(\"dense next-item training produced non-finite loss\")\n if backward:\n (loss_sum / total_weight).backward()\n if sequence_input.grad is None:\n raise RuntimeError(\"sequence microbatch did not produce a gradient\")\n sequence_gradient[begin:end] = sequence_input.grad\n statistics[\"loss_sum\"] += float(loss_sum.detach().cpu())\n statistics[\"positive_sum\"] += float(positive_logits.detach().sum().cpu())\n statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n statistics[\"pair_correct\"] += float(\n (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n )\n statistics[\"weight_sum\"] += float(selected_weights.sum().detach().cpu())\n return statistics, sequence_gradient\n\n\ndef release_accelerator_cache(device: torch.device) -> None:\n if device.type == \"mps\":\n torch.mps.empty_cache()\n\n\ndef run_training_epoch(\n model: base.SequentialTwoTower,\n optimizer: torch.optim.Optimizer,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n target_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n generator: torch.Generator,\n device: torch.device,\n epoch: int,\n) -> dict[str, float]:\n model.train()\n train_users = np.flatnonzero(target_counts > 0)\n sorted_users = train_users[\n np.argsort(target_counts[train_users], kind=\"stable\")\n ]\n groups = [\n sorted_users[start : start + config.user_batch_size]\n for start in range(0, len(sorted_users), config.user_batch_size)\n ]\n group_order = torch.randperm(len(groups), generator=generator).numpy()\n negative_count = config.uniform_negatives + config.inbatch_negatives\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n total_positive = 0.0\n total_negative = 0.0\n total_pair_correct = 0.0\n batches = 0\n started = time.monotonic()\n\n for group_number in group_order:\n users = groups[int(group_number)]\n group_counts = target_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_counts.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_counts,\n np.zeros(len(users), dtype=np.int64),\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0).detach()\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n optimizer.zero_grad(set_to_none=True)\n statistics, sequence_gradient = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=True,\n )\n if sequence_gradient is None:\n raise RuntimeError(\"training block omitted its sequence gradient\")\n sequence_vectors.backward(sequence_gradient)\n nn.utils.clip_grad_norm_(model.parameters(), config.gradient_clip)\n optimizer.step()\n\n count = len(positive_items)\n total_loss += statistics[\"loss_sum\"]\n total_targets += count\n total_weight += statistics[\"weight_sum\"]\n total_positive += statistics[\"positive_sum\"]\n total_negative += statistics[\"negative_sum\"]\n total_pair_correct += statistics[\"pair_correct\"]\n batches += 1\n if batches % 25 == 0:\n release_accelerator_cache(device)\n if batches % 250 == 0:\n batch_bce = statistics[\"loss_sum\"] / max(\n statistics[\"weight_sum\"], 1.0e-12\n )\n running_bce = total_loss / max(total_weight, 1.0e-12)\n log(\n f\"epoch={epoch}/{config.epochs} batches={batches:,} \"\n f\"targets={total_targets:,} \"\n f\"batch_bce={batch_bce:.6f} \"\n f\"running_bce={running_bce:.6f} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n return {\n \"epoch\": float(epoch),\n \"loss\": total_loss / max(total_weight, 1.0e-12),\n \"positive_logit\": total_positive / max(total_targets, 1),\n \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n \"pair_accuracy\": total_pair_correct\n / max(total_targets * negative_count, 1),\n \"targets\": float(total_targets),\n \"target_weight_sum\": total_weight,\n \"batches\": float(batches),\n \"seconds\": time.monotonic() - started,\n }\n\n\[email protected]_grad()\ndef evaluate_validation_bce(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n training_counts: np.ndarray,\n validation_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n device: torch.device,\n) -> dict[str, float]:\n \"\"\"Measure deterministic BCE on each user's chronological target suffix.\"\"\"\n model.eval()\n totals = training_counts + validation_counts\n users_with_validation = np.flatnonzero(validation_counts > 0)\n ordered = users_with_validation[\n np.argsort(totals[users_with_validation], kind=\"stable\")\n ]\n groups = [\n ordered[start : start + config.user_batch_size]\n for start in range(0, len(ordered), config.user_batch_size)\n ]\n generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 20_000)\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n started = time.monotonic()\n\n for group_number, users in enumerate(groups, start=1):\n group_totals = totals[users]\n group_starts = training_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_totals.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_totals,\n group_starts,\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0)\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n statistics, _ = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=False,\n )\n total_loss += statistics[\"loss_sum\"]\n total_targets += len(positive_items)\n total_weight += statistics[\"weight_sum\"]\n if group_number % 25 == 0:\n release_accelerator_cache(device)\n return {\n \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n \"validation_targets\": float(total_targets),\n \"validation_target_weight_sum\": total_weight,\n \"validation_seconds\": time.monotonic() - started,\n }\n\n\ndef snapshot_model(model: nn.Module) -> dict[str, torch.Tensor]:\n return {\n name: value.detach().cpu().clone()\n for name, value in model.state_dict().items()\n }\n\n\ndef save_epoch_checkpoint(\n checkpoint_dir: Path,\n epoch: int,\n model_state: dict[str, torch.Tensor],\n config: Config,\n diagnostics: dict[str, float],\n optimizer: torch.optim.Optimizer,\n generator: torch.Generator,\n) -> Path:\n checkpoint_dir.mkdir(parents=True, exist_ok=True)\n path = checkpoint_dir / f\"epoch-{epoch:02d}.pt\"\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": model_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n path,\n )\n log(f\"saved epoch checkpoint: {path}\")\n return path\n\n\ndef train_model(\n public: Path,\n stage: str,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n checkpoint_dir: Path | None,\n resume_checkpoint: Path | None,\n) -> tuple[base.SequentialTwoTower, list[dict[str, float]]]:\n partition = chronological_target_partition(\n prepared,\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n training_counts = partition.training_counts\n validation_counts = partition.validation_counts\n log(\n f\"chronological BCE window: train={int(training_counts.sum()):,} \"\n f\"validation={int(validation_counts.sum()):,} \"\n f\"selected={partition.selected_count:,} \"\n f\"discarded_old_targets={int(partition.window_starts.sum()):,} \"\n f\"all={prepared.target_count:,}\"\n )\n model = base.SequentialTwoTower(prepared.item_features, model_config(config)).to(\n device\n )\n optimizer = torch.optim.AdamW(\n model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay\n )\n",
"exit_code": 0,
"duration": {
"secs": 0,
"nanos": 5010
},
"formatted_output": "Warning: truncated output (original token count: 13171)\nTotal output lines: 1346\n\n7:16 held-out validation targets; passing zero for either cap restores the\n14:and 24 uniform plus eight propensity-corrected unique in-batch negatives. Every\n20:import argparse\n56: uniform_negatives: int = 24\n57: inbatch_negatives: int = 8\n61: negative_filter_scope: str = \"full-training-history\"\n67: validation_fraction: float = 0.10\n69: max_validation_targets_per_user: int = 16\n132: validation_counts: np.ndarray\n137: return int(self.training_counts.sum() + self.validation_counts.sum())\n152: return base.TowerConfig(\n165: uniform_negatives=config.uniform_negatives,\n166: inbatch_negatives=config.inbatch_negatives,\n346: \"popularity_rank\"\n354: if stage == \"validation\":\n357: candidate_name = \"validation_eligible_candidates.npz\"\n358: state_name = \"validation_preference_state.parquet\"\n477: \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n518: \"validation_eligible_candidates.npz\"\n519: if stage == \"validation\"\n603: f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n609:def invalid_full_history_negatives(\n610: negatives: np.ndarray,\n616: \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n617: byte_columns = negatives >> 3\n618: bit_columns = negatives & 7\n621: invalid |= negatives == targets[:, None]\n622: invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n623: for column in range(1, negatives.shape[1]):\n625: negatives[:, column, None] == negatives[:, :column]\n630:def draw_full_history_filtered_negatives(\n641: \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n647: (batch_size, config.uniform_negatives),\n653: if config.inbatch_negatives:\n656: config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n672: if corrected_count < config.inbatch_negatives:\n675: (batch_size, config.inbatch_negatives - corrected_count),\n681: negatives = torch.cat([uniform, inbatch], dim=1).numpy()\n682: correction = np.ones_like(negatives, dtype=np.float32)\n685: :, config.uniform_negatives : config.uniform_negatives + corrected_count\n688: invalid = invalid_full_history_negatives(\n689: negatives,\n697: torch.from_numpy(negatives).to(device),\n706: negatives[invalid] = replacements\n709: invalid = invalid_full_history_negatives(\n710: negatives,\n717: raise RuntimeError(\"full-history negative resampling failed\")\n719: torch.from_numpy(negatives).to(device),\n724:def draw_training_negatives(\n736: if config.negative_filter_scope == \"full-training-history\":\n738: raise RuntimeError(\"full-history negative filter was not built\")\n739: return draw_full_history_filtered_negatives(\n750: return base.draw_filtered_mixed_negatives(\n755: config.uniform_negatives,\n756: config.inbatch_negatives,\n816: negatives: torch.Tensor,\n818: all_items = torch.cat([positives, negatives.reshape(-1)])\n822: negative_vectors = encoded[inverse[len(positives) :]].reshape(\n823: len(positives), negatives.shape[1], -1\n825: return positive_vectors, negative_vectors\n845:def corrected_negative_loss(\n851: total = config.uniform_negatives + config.inbatch_negatives\n853: if config.uniform_negatives:\n854: result += config.uniform_negatives * raw[\n855: :, : config.uniform_negatives\n857: if config.inbatch_negatives:\n858: selected = slice(config.uniform_negatives, total)\n860: result += config.inbatch_negatives * (\n869: validation_counts: np.ndarray,\n873: training_region_ends = total_targets - validation_counts\n878: or np.any(validation_counts < 0)\n880: raise RuntimeError(\"chronological target partition produced negative counts\")\n882: window_starts + training_counts + validation_counts, total_targets\n890: validation_fraction: float,\n892: max_validation_targets_per_user: int,\n896: validation = np.floor(totals * validation_fraction).astype(np.int64)\n897: validation[(totals >= 2) & (validation == 0)] = 1\n898: if max_validation_targets_per_user:\n899: validation = np.minimum(validation, max_validation_targets_per_user)\n901: history_training = totals - validation\n905: window_starts = retained_target_window_starts(totals, validation, training)\n909: validation_counts=validation,\n918: prefixes = (\"train\",) if stage == \"validation\" else (\"train\", \"val\")\n981: base.TRAIN_END if stage == \"validation\" else base.VALIDATION_END\n991: first_validation_target = last_training_target + 1\n993: int(prepared.sequence_timestamps[first_validation_target])\n994: if first_validation_target < end\n1119: negatives: torch.Tensor,\n1120: negative_weights: torch.Tensor,\n1134: \"negative_sum\": 0.0,\n1162: positive_vectors, negative_vectors = candidate_vectors(\n1165: negatives[begin:end],\n1168: negative_logits = torch.einsum(\n1169: \"bd,bkd->bk\", query_vectors, negative_vectors\n1172: negative_loss = corrected_negative_loss(\n1173: negative_logits, negative_weights[begin:end], config\n1179: selected_weights * (positive_loss + negative_loss) / 2.0\n1190: statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n1192: (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n1227: negative_count = config.uniform_negatives + config.inbatch_negatives\n1232: total_negative = 0.0\n1293: negatives, negative_weights = draw_training_negatives(\n1314: negatives,\n1315: negative_weights,\n1331: total_negative += statistics[\"negative_sum\"]\n1353: \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n1355: / max(total_targets * negative_count, 1),\n1364:def evaluate_validation_bce(\n1371: validation_counts: np.ndarray,\n1378: totals = training_counts + validation_counts\n1379: users_with_validation = np.flatnonzero(validation_counts > 0)\n1380: ordered = users_with_validation[\n1381: np.argsort(totals[users_with_validation], kind=\"stable\")\n1449: negatives, negative_weights = draw_training_negatives(\n1469: negatives,\n1470: negative_weights,\n1481: \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n1482: \"validation_targets\": float(total_targets),\n1483: \"validation_target_weight_sum\": total_weight,\n1484: \"validation_seconds\": time.monotonic() - started,\n1521:def train_model(\n1532: config.validation_fraction,\n1534: config.max_validation_targets_per_user,\n1537: validation_counts = partition.validation_counts\n1540: f\"validation={int(validation_counts.sum()):,} \"\n1567: if config.negative_filter_scope == \"full-training-history\"\n1601: \"negative_filter_scope\", \"last-100\"\n1619: stored_config.get(\"validation_fraction\", 0.10),\n1621: stored_config.get(\"max_validation_targets_per_user\", 0),\n1624: config.validation_fraction,\n1626: config.max_validation_targets_per_user,\n1629: stored_filter_scope != config.negative_filter_scope\n1657: prior.pop(\"validation_loss\", None)\n1660: f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n1664: \"recomputing the resume checkpoint's validation BCE\"\n1666: if \"validation_loss\" not in prior:\n1668: evaluate_validation_bce(\n1675: validation_counts,\n1682: best_loss = float(prior[\"validation_loss\"])\n1686: f\"resumed validation-BCE training: epoch={start_epoch} \"\n1705: validation = evaluate_validation_bce(\n1712: validation_counts,\n1717: diagnostics.update(validation)\n1731: diagnostics[\"validation_loss\"]\n1735: best_loss = diagnostics[\"validation_loss\"]\n1755: f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n1758: f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n1760: f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n1766: f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n1775: f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n1861:def rank_candidates(\n1867: ranking_batch_size: int,\n1882: for start in range(0, len(eligibility.uids), ranking_batch_size):\n1883: end = min(start + ranking_batch_size, len(eligibility.uids))\n1896: (uid, int(prepared.item_ids[column]), rank)\n1897: for rank, column in enumerate(columns, start=1)\n1900: log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n1909: return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n1912:def write_ranking(frame: pd.DataFrame, output: Path) -> None:\n1923: parser = argparse.ArgumentParser(description=__doc__)\n input_size=config.hidden_dim,\n hidden_size=dim,\n num_layers=1,\n batch_first=True,\n )\n self.user_tower = nn.Sequential(\n nn.Linear(dim * 3 + 6, config.user_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_1, config.user_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.user_hidden_2, dim),\n )\n self.candidate_embedding = nn.Embedding(n_items, dim)\n self.register_buffer(\n \"item_features\", torch.from_numpy(item_features), persistent=True\n )\n self.item_tower = nn.Sequential(\n nn.Linear(dim + n_item_features, config.item_hidden_1),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_1, config.item_hidden_2),\n nn.GELU(),\n nn.Dropout(config.dropout),\n nn.Linear(config.item_hidden_2, dim),\n )\n nn.init.normal_(self.context_embedding.weight, std=0.02)\n nn.init.normal_(self.candidate_embedding.weight, std=0.02)\n with torch.no_grad():\n self.context_embedding.weight[n_items].zero_()\n\n def _mean_context_channel(self, items: torch.Tensor) -> torch.Tensor:\n mask = items.ne(self.n_items)\n vectors = self.context_embedding(items)\n return (vectors * mask.unsqueeze(-1)).sum(dim=1) / mask.sum(\n dim=1, keepdim=True\n ).clamp_min(1)\n\n def encode_items(self, items: torch.Tensor) -> torch.Tensor:\n inputs = torch.cat(\n [self.candidate_embedding(items), self.item_features[items]], dim=1\n )\n return self.item_tower(inputs)\n\n\ndef invalid_negative_mask(\n negatives: torch.Tensor,\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n) -> torch.Tensor:\n \"\"\"Identify targets, known positives, active likes, and row duplicates.\"\"\"\n invalid = negatives.eq(targets[:, None])\n invalid |= negatives[:, :, None].eq(context_items[:, None, :]).any(dim=2)\n invalid |= negatives[:, :, None].eq(liked_items[:, None, :]).any(dim=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= negatives[:, column, None].eq(\n negatives[:, :column]\n ).any(dim=1)\n return invalid\n\n\ndef draw_filtered_mixed_negatives(\n targets: torch.Tensor,\n context_items: torch.Tensor,\n liked_items: torch.Tensor,\n n_items: int,\n uniform_count: int,\n inbatch_count: int,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw uniform and unique-target in-batch negatives.\"\"\"\n batch_size = len(targets)\n uniform = torch.randint(\n n_items,\n (batch_size, uniform_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long, device=\"cpu\")\n corrected_count = 0\n if inbatch_count:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(inbatch_count, max(len(unique_targets) - 1, 0))\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n )\n priorities.masked_fill_(\n unique_targets[None, :].eq(targets.detach().cpu()[:, None]),\n -1.0,\n )\n columns = torch.topk(priorities, k=corrected_count, dim=1).indices\n inbatch = unique_targets[columns]\n if corrected_count < inbatch_count:\n filler = torch.randint(\n n_items,\n (batch_size, inbatch_count - corrected_count),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.cat([inbatch, filler], dim=1)\n\n negatives_cpu = torch.cat([uniform, inbatch], dim=1)\n correction_cpu = torch.ones_like(negatives_cpu, dtype=torch.float32)\n if corrected_count:\n correction_cpu[\n :, uniform_count : uniform_count + corrected_count\n ] = item_correction[inbatch[:, :corrected_count]]\n negatives = negatives_cpu.to(device)\n correction = correction_cpu.to(device)\n for _ in range(32):\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if not bool(invalid.any()):\n return negatives, correction\n replacements = torch.randint(\n n_items,\n (int(invalid.sum().detach().cpu()),),\n generator=generator,\n device=\"cpu\",\n ).to(device)\n negatives[invalid] = replacements\n correction[invalid] = 1.0\n invalid = invalid_negative_mask(\n negatives, targets, context_items, liked_items\n )\n if bool(invalid.any()):\n raise RuntimeError(\"mixed negative resampling failed false-negative filter\")\n return negatives, correction\n field: artifact[field].copy()\n for field in SequenceData.__dataclass_fields__\n }\n prepared = SequenceData(**values)\n log(\n f\"loaded full-sequence cache: tokens={len(prepared.sequence_items):,} \"\n f\"targets={prepared.target_count:,}\"\n )\n return prepared\n\n\ndef load_or_prepare(\n public: Path,\n stage: str,\n config: Config,\n cache: Path | None,\n) -> tuple[SequenceData, CandidateSet]:\n candidate_name = (\n \"validation_eligible_candidates.npz\"\n if stage == \"validation\"\n else \"test_eligible_candidates.npz\"\n )\n eligibility = CandidateSet.read(public / \"data\" / candidate_name)\n if cache is not None and cache.is_file():\n prepared = load_cache(cache, stage, config)\n if not np.array_equal(prepared.item_ids, eligibility.item_ids):\n raise RuntimeError(\"cached catalog differs from candidate artifact\")\n return prepared, eligibility\n prepared, built_eligibility = prepare_data(public, stage, config)\n if cache is not None:\n save_cache(cache, prepared, stage, config)\n return prepared, built_eligibility\n\n\ndef build_item_correction(\n prepared: SequenceData,\n config: Config,\n target_starts: np.ndarray,\n target_counts: np.ndarray,\n) -> torch.Tensor:\n \"\"\"Estimate in-batch propensities from the selected training targets.\"\"\"\n counts = np.zeros(len(prepared.item_ids), dtype=np.float64)\n for user_row, count in enumerate(target_counts):\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n target_start = int(target_starts[user_row])\n counts += np.bincount(\n prepared.sequence_items[\n offset\n + target_start\n + 1 : offset\n + target_start\n + int(count)\n + 1\n ].astype(np.int64),\n minlength=len(prepared.item_ids),\n )\n total = float(counts.sum())\n probabilities = (counts + 1.0) / (total + len(counts))\n raw = np.power(\n (1.0 / len(counts)) / probabilities,\n config.inbatch_correction_power,\n )\n normalization = float(np.sum(counts * raw) / max(total, 1.0))\n correction = np.clip(\n raw / max(normalization, 1.0e-12),\n config.inbatch_correction_min,\n config.inbatch_correction_max,\n ).astype(np.float32)\n mean_on_targets = float(np.sum(counts * correction) / max(total, 1.0))\n log(\n f\"inbatch correction: min={correction.min():.3f} \"\n f\"mean_on_targets={mean_on_targets:.3f} max={correction.max():.3f}\"\n )\n return torch.from_numpy(correction)\n\n\ndef build_known_positive_filter(\n prepared: SequenceData, training_counts: np.ndarray\n) -> np.ndarray:\n \"\"\"Pack each user's complete public-training positive-item set.\"\"\"\n width = (len(prepared.item_ids) + 7) // 8\n packed = np.zeros((len(prepared.user_ids), width), dtype=np.uint8)\n retained = 0\n for user_row, target_count in enumerate(training_counts):\n if not target_count:\n continue\n offset = int(prepared.offsets[user_row])\n # T adjacent targets consume sequence positions 0 through T. The\n # chronological BCE suffix starts at T + 1 and is intentionally absent.\n items = np.unique(\n prepared.sequence_items[\n offset : offset + int(target_count) + 1\n ].astype(np.int64)\n )\n byte_columns = items >> 3\n bit_values = np.left_shift(\n np.uint8(1), (items & 7).astype(np.uint8)\n )\n np.bitwise_or.at(packed[user_row], byte_columns, bit_values)\n retained += len(items)\n log(\n f\"full-history false-negative filter: users={len(prepared.user_ids):,} \"\n f\"unique_user_items={retained:,} bytes={packed.nbytes:,}\"\n )\n return packed\n\n\ndef invalid_full_history_negatives(\n negatives: np.ndarray,\n targets: np.ndarray,\n user_rows: np.ndarray,\n known_positive_packed: np.ndarray,\n liked_items: np.ndarray,\n) -> np.ndarray:\n \"\"\"Return row-level invalid negatives using the complete training prefix.\"\"\"\n byte_columns = negatives >> 3\n bit_columns = negatives & 7\n known = known_positive_packed[user_rows[:, None], byte_columns]\n invalid = ((known >> bit_columns) & 1).astype(bool)\n invalid |= negatives == targets[:, None]\n invalid |= (negatives[:, :, None] == liked_items[:, None, :]).any(axis=2)\n for column in range(1, negatives.shape[1]):\n invalid[:, column] |= (\n negatives[:, column, None] == negatives[:, :column]\n ).any(axis=1)\n return invalid\n\n\ndef draw_full_history_filtered_negatives(\n targets: torch.Tensor,\n user_rows: np.ndarray,\n liked_items: torch.Tensor,\n known_positive_packed: np.ndarray,\n n_items: int,\n config: Config,\n item_correction: torch.Tensor,\n generator: torch.Generator,\n device: torch.device,\n) -> tuple[torch.Tensor, torch.Tensor]:\n \"\"\"Draw mixed negatives and reject all known public-training positives.\"\"\"\n targets_cpu = targets.detach().cpu().numpy().astype(np.int64, copy=False)\n liked_cpu = liked_items.detach().cpu().numpy().astype(np.int64, copy=False)\n batch_size = len(targets_cpu)\n uniform = torch.randint(\n n_items,\n (batch_size, config.uniform_negatives),\n generator=generator,\n device=\"cpu\",\n )\n inbatch = torch.empty((batch_size, 0), dtype=torch.long)\n corrected_count = 0\n if config.inbatch_negatives:\n unique_targets = torch.unique(targets.detach().cpu())\n corrected_count = min(\n config.inbatch_negatives, max(len(unique_targets) - 1, 0)\n )\n if corrected_count:\n priorities = torch.rand(\n (batch_size, len(unique_targets)), generator=generator\n …3171 tokens truncated…tamp + horizon,\n split_timestamp\n if position <= last_training_target\n else history_end,\n )\n outcome = int(np.searchsorted(timestamps, timestamp, side=\"left\"))\n if outcome < len(timestamps) and int(timestamps[outcome]) < upper:\n weights[position] = config.organic_like_target_weight\n grade_three += 1\n log(\n f\"grade-3 first-encounter targets={grade_three:,} \"\n f\"horizon_days={config.discovery_horizon_days}\"\n )\n log(\n f\"target weighting={config.target_weighting} \"\n f\"min={weights.min():.3f} mean={weights.mean():.3f} \"\n f\"max={weights.max():.3f}\"\n )\n return weights\n\n\ndef build_dense_block(\n prepared: SequenceData,\n target_weights: np.ndarray,\n users: np.ndarray,\n window_starts: np.ndarray,\n process_counts: np.ndarray,\n score_starts: np.ndarray,\n start: int,\n steps: int,\n config: Config,\n) -> tuple[np.ndarray, ...]:\n \"\"\"Build one causal sequence block and mark the target rows to score.\"\"\"\n batch_size = len(users)\n padding_item = len(prepared.item_ids)\n input_items = np.full((batch_size, steps), padding_item, dtype=np.uint16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n event_features = np.zeros((batch_size, steps, 3), dtype=np.float16)\n activity_features = np.zeros((batch_size, steps, 4), dtype=np.float16)\n state_ids = np.zeros((batch_size, steps), dtype=np.uint32)\n targets = np.zeros((batch_size, steps), dtype=np.uint16)\n weights = np.ones((batch_size, steps), dtype=np.float32)\n scored = np.zeros((batch_size, steps), dtype=bool)\n histories = np.full(\n (batch_size, steps, config.bptt_steps),\n padding_item,\n dtype=np.uint16,\n )\n processed = np.zeros(batch_size, dtype=np.int32)\n\n lengths = prepared.lengths\n for row, user_row in enumerate(users):\n count = min(steps, max(int(process_counts[row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[user_row])\n window_start = int(window_starts[row])\n absolute_start = window_start + start\n positions = slice(\n offset + absolute_start,\n offset + absolute_start + count,\n )\n target_positions = slice(\n offset + absolute_start + 1,\n offset + absolute_start + count + 1,\n )\n input_items[row, :count] = prepared.sequence_items[positions]\n event_features[row, :count] = prepared.event_features[positions]\n activity_features[row, :count] = prepared.activity_features[positions]\n state_ids[row, :count] = prepared.state_ids[positions]\n targets[row, :count] = prepared.sequence_items[target_positions]\n weights[row, :count] = target_weights[target_positions]\n score_from = min(count, max(int(score_starts[row]) - start, 0))\n scored[row, score_from:count] = True\n processed[row] = count\n user_sequence = prepared.sequence_items[\n offset : offset + int(lengths[user_row])\n ]\n histories[row, :count] = history_windows(\n user_sequence,\n absolute_start,\n count,\n config.bptt_steps,\n padding_item,\n )\n return (\n input_items,\n event_features,\n activity_features,\n state_ids,\n targets,\n scored,\n histories,\n processed,\n weights,\n )\n\n\ndef score_target_microbatches(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n sequence_vectors: torch.Tensor,\n flat_states: np.ndarray,\n flat_activity: np.ndarray,\n positive_items: torch.Tensor,\n negatives: torch.Tensor,\n negative_weights: torch.Tensor,\n sample_weights: np.ndarray,\n device: torch.device,\n backward: bool,\n) -> tuple[dict[str, float], torch.Tensor | None]:\n \"\"\"Compute one logical block's exact BCE in bounded-memory slices.\"\"\"\n target_count = len(positive_items)\n total_weight = float(sample_weights.sum())\n if total_weight <= 0:\n raise RuntimeError(\"target block has no positive sample weight\")\n sequence_gradient = torch.zeros_like(sequence_vectors) if backward else None\n statistics = {\n \"loss_sum\": 0.0,\n \"positive_sum\": 0.0,\n \"negative_sum\": 0.0,\n \"pair_correct\": 0.0,\n \"weight_sum\": 0.0,\n }\n for begin in range(0, target_count, config.loss_microbatch_size):\n end = min(begin + config.loss_microbatch_size, target_count)\n selected_states = flat_states[begin:end]\n if backward:\n sequence_input = (\n sequence_vectors[begin:end].detach().requires_grad_(True)\n )\n else:\n sequence_input = sequence_vectors[begin:end]\n liked_vectors, disliked_vectors, _ = pool_explicit_states(\n model, prepared, selected_states, device\n )\n counts = prepared.state_counts[selected_states]\n numeric = np.zeros((end - begin, 6), dtype=np.float32)\n numeric[:, :4] = flat_activity[begin:end]\n numeric[:, 4] = np.log1p(counts[:, 0]) / 6.0\n numeric[:, 5] = np.log1p(counts[:, 1]) / 4.0\n query_vectors = user_queries(\n model,\n sequence_input,\n liked_vectors,\n disliked_vectors,\n torch.from_numpy(numeric).to(device),\n )\n positive_vectors, negative_vectors = candidate_vectors(\n model,\n positive_items[begin:end],\n negatives[begin:end],\n )\n positive_logits = (query_vectors * positive_vectors).sum(dim=1)\n negative_logits = torch.einsum(\n \"bd,bkd->bk\", query_vectors, negative_vectors\n )\n positive_loss = F.softplus(-positive_logits)\n negative_loss = corrected_negative_loss(\n negative_logits, negative_weights[begin:end], config\n )\n selected_weights = torch.from_numpy(\n sample_weights[begin:end].astype(np.float32, copy=False)\n ).to(device)\n loss_sum = (\n selected_weights * (positive_loss + negative_loss) / 2.0\n ).sum()\n if not bool(torch.isfinite(loss_sum)):\n raise RuntimeError(\"dense next-item training produced non-finite loss\")\n if backward:\n (loss_sum / total_weight).backward()\n if sequence_input.grad is None:\n raise RuntimeError(\"sequence microbatch did not produce a gradient\")\n sequence_gradient[begin:end] = sequence_input.grad\n statistics[\"loss_sum\"] += float(loss_sum.detach().cpu())\n statistics[\"positive_sum\"] += float(positive_logits.detach().sum().cpu())\n statistics[\"negative_sum\"] += float(negative_logits.detach().sum().cpu())\n statistics[\"pair_correct\"] += float(\n (positive_logits.detach()[:, None] > negative_logits.detach()).sum().cpu()\n )\n statistics[\"weight_sum\"] += float(selected_weights.sum().detach().cpu())\n return statistics, sequence_gradient\n\n\ndef release_accelerator_cache(device: torch.device) -> None:\n if device.type == \"mps\":\n torch.mps.empty_cache()\n\n\ndef run_training_epoch(\n model: base.SequentialTwoTower,\n optimizer: torch.optim.Optimizer,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n target_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n generator: torch.Generator,\n device: torch.device,\n epoch: int,\n) -> dict[str, float]:\n model.train()\n train_users = np.flatnonzero(target_counts > 0)\n sorted_users = train_users[\n np.argsort(target_counts[train_users], kind=\"stable\")\n ]\n groups = [\n sorted_users[start : start + config.user_batch_size]\n for start in range(0, len(sorted_users), config.user_batch_size)\n ]\n group_order = torch.randperm(len(groups), generator=generator).numpy()\n negative_count = config.uniform_negatives + config.inbatch_negatives\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n total_positive = 0.0\n total_negative = 0.0\n total_pair_correct = 0.0\n batches = 0\n started = time.monotonic()\n\n for group_number in group_order:\n users = groups[int(group_number)]\n group_counts = target_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_counts.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_counts,\n np.zeros(len(users), dtype=np.int64),\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0).detach()\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n optimizer.zero_grad(set_to_none=True)\n statistics, sequence_gradient = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=True,\n )\n if sequence_gradient is None:\n raise RuntimeError(\"training block omitted its sequence gradient\")\n sequence_vectors.backward(sequence_gradient)\n nn.utils.clip_grad_norm_(model.parameters(), config.gradient_clip)\n optimizer.step()\n\n count = len(positive_items)\n total_loss += statistics[\"loss_sum\"]\n total_targets += count\n total_weight += statistics[\"weight_sum\"]\n total_positive += statistics[\"positive_sum\"]\n total_negative += statistics[\"negative_sum\"]\n total_pair_correct += statistics[\"pair_correct\"]\n batches += 1\n if batches % 25 == 0:\n release_accelerator_cache(device)\n if batches % 250 == 0:\n batch_bce = statistics[\"loss_sum\"] / max(\n statistics[\"weight_sum\"], 1.0e-12\n )\n running_bce = total_loss / max(total_weight, 1.0e-12)\n log(\n f\"epoch={epoch}/{config.epochs} batches={batches:,} \"\n f\"targets={total_targets:,} \"\n f\"batch_bce={batch_bce:.6f} \"\n f\"running_bce={running_bce:.6f} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n return {\n \"epoch\": float(epoch),\n \"loss\": total_loss / max(total_weight, 1.0e-12),\n \"positive_logit\": total_positive / max(total_targets, 1),\n \"negative_logit\": total_negative / max(total_targets * negative_count, 1),\n \"pair_accuracy\": total_pair_correct\n / max(total_targets * negative_count, 1),\n \"targets\": float(total_targets),\n \"target_weight_sum\": total_weight,\n \"batches\": float(batches),\n \"seconds\": time.monotonic() - started,\n }\n\n\[email protected]_grad()\ndef evaluate_validation_bce(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n target_weights: np.ndarray,\n window_starts: np.ndarray,\n training_counts: np.ndarray,\n validation_counts: np.ndarray,\n item_correction: torch.Tensor,\n known_positive_packed: np.ndarray | None,\n device: torch.device,\n) -> dict[str, float]:\n \"\"\"Measure deterministic BCE on each user's chronological target suffix.\"\"\"\n model.eval()\n totals = training_counts + validation_counts\n users_with_validation = np.flatnonzero(validation_counts > 0)\n ordered = users_with_validation[\n np.argsort(totals[users_with_validation], kind=\"stable\")\n ]\n groups = [\n ordered[start : start + config.user_batch_size]\n for start in range(0, len(ordered), config.user_batch_size)\n ]\n generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 20_000)\n total_loss = 0.0\n total_targets = 0\n total_weight = 0.0\n started = time.monotonic()\n\n for group_number, users in enumerate(groups, start=1):\n group_totals = totals[users]\n group_starts = training_counts[users]\n hidden = torch.zeros(\n 1, len(users), config.embedding_dim, device=device\n )\n max_targets = int(group_totals.max())\n for start in range(0, max_targets, config.bptt_steps):\n steps = min(config.bptt_steps, max_targets - start)\n block = build_dense_block(\n prepared,\n target_weights,\n users,\n window_starts[users],\n group_totals,\n group_starts,\n start,\n steps,\n config,\n )\n sequence_outputs, _ = event_sequence(\n model,\n torch.from_numpy(block[0].astype(np.int64)).to(device),\n torch.from_numpy(block[1].astype(np.float32)).to(device),\n hidden,\n )\n hidden = torch.stack(\n [\n sequence_outputs[row, count - 1]\n if count\n else hidden[0, row]\n for row, count in enumerate(block[7])\n ]\n ).unsqueeze(0)\n flat_scored = block[5].reshape(-1)\n if not np.any(flat_scored):\n continue\n mask = torch.from_numpy(flat_scored).to(device)\n sequence_vectors = sequence_outputs.reshape(\n -1, config.embedding_dim\n )[mask]\n flat_states = block[3].reshape(-1)[flat_scored]\n positive_items = torch.from_numpy(\n block[4].reshape(-1)[flat_scored].astype(np.int64)\n ).to(device)\n known_history = torch.from_numpy(\n block[6].reshape(-1, config.bptt_steps)[flat_scored].astype(\n np.int64\n )\n ).to(device)\n liked_items = torch.from_numpy(\n prepared.liked_states[flat_states].astype(np.int64)\n ).to(device)\n flat_user_rows = np.broadcast_to(\n users[:, None], block[5].shape\n ).reshape(-1)[flat_scored]\n negatives, negative_weights = draw_training_negatives(\n positive_items,\n known_history,\n liked_items,\n flat_user_rows,\n known_positive_packed,\n prepared,\n config,\n item_correction,\n generator,\n device,\n )\n statistics, _ = score_target_microbatches(\n model,\n prepared,\n config,\n sequence_vectors,\n flat_states,\n block[2].reshape(-1, 4)[flat_scored].astype(np.float32),\n positive_items,\n negatives,\n negative_weights,\n block[8].reshape(-1)[flat_scored],\n device,\n backward=False,\n )\n total_loss += statistics[\"loss_sum\"]\n total_targets += len(positive_items)\n total_weight += statistics[\"weight_sum\"]\n if group_number % 25 == 0:\n release_accelerator_cache(device)\n return {\n \"validation_loss\": total_loss / max(total_weight, 1.0e-12),\n \"validation_targets\": float(total_targets),\n \"validation_target_weight_sum\": total_weight,\n \"validation_seconds\": time.monotonic() - started,\n }\n\n\ndef snapshot_model(model: nn.Module) -> dict[str, torch.Tensor]:\n return {\n name: value.detach().cpu().clone()\n for name, value in model.state_dict().items()\n }\n\n\ndef save_epoch_checkpoint(\n checkpoint_dir: Path,\n epoch: int,\n model_state: dict[str, torch.Tensor],\n config: Config,\n diagnostics: dict[str, float],\n optimizer: torch.optim.Optimizer,\n generator: torch.Generator,\n) -> Path:\n checkpoint_dir.mkdir(parents=True, exist_ok=True)\n path = checkpoint_dir / f\"epoch-{epoch:02d}.pt\"\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": model_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n path,\n )\n log(f\"saved epoch checkpoint: {path}\")\n return path\n\n\ndef train_model(\n public: Path,\n stage: str,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n checkpoint_dir: Path | None,\n resume_checkpoint: Path | None,\n) -> tuple[base.SequentialTwoTower, list[dict[str, float]]]:\n partition = chronological_target_partition(\n prepared,\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n training_counts = partition.training_counts\n validation_counts = partition.validation_counts\n log(\n f\"chronological BCE window: train={int(training_counts.sum()):,} \"\n f\"validation={int(validation_counts.sum()):,} \"\n f\"selected={partition.selected_count:,} \"\n f\"discarded_old_targets={int(partition.window_starts.sum()):,} \"\n f\"all={prepared.target_count:,}\"\n )\n model = base.SequentialTwoTower(prepared.item_features, model_config(config)).to(\n device\n )\n optimizer = torch.optim.AdamW(\n model.parameters(), lr=config.learning_rate, weight_decay=config.weight_decay\n )\n"
}
}