Harness context codex item completed
{
"type": "codex_item_completed",
"item": {
"type": "CommandExecution",
"id": "exec-e83d4d1b-7214-41cb-a7bf-39a9a2301780",
"process_id": "52203",
"command": [
"/bin/sh",
"-lc",
"sed -n '300,630p' /app/solution/train_current_model.py; sed -n '1040,1520p' /app/solution/train_current_model.py; sed -n '1520,1920p' /app/solution/train_current_model.py"
],
"cwd": "file:///app",
"parsed_cmd": [
{
"type": "read",
"cmd": "sed -n '300,630p' /app/solution/train_current_model.py",
"name": "train_current_model.py",
"path": "/app/solution/train_current_model.py"
},
{
"type": "read",
"cmd": "sed -n '1040,1520p' /app/solution/train_current_model.py",
"name": "train_current_model.py",
"path": "/app/solution/train_current_model.py"
},
{
"type": "read",
"cmd": "sed -n '1520,1920p' /app/solution/train_current_model.py",
"name": "train_current_model.py",
"path": "/app/solution/train_current_model.py"
}
],
"source": "unified_exec_startup",
"status": "completed",
"stdout": " changed = True\n if changed:\n current_state = append_state_snapshot(\n active_likes,\n active_dislikes,\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n states[row] = np.uint32(current_state)\n return timestamps, items.copy(), event_features, activity, states\n\n\ndef final_user_features(\n timestamps: np.ndarray,\n items: np.ndarray,\n event_features: np.ndarray,\n history_end: int,\n like_count: int,\n dislike_count: int,\n) -> np.ndarray:\n start = int(\n np.searchsorted(\n timestamps,\n history_end - base.PROFILE_LOOKBACK_DAYS * base.DAY,\n side=\"left\",\n )\n )\n result = np.zeros(6, dtype=np.float32)\n recent_items = items[start:]\n if len(recent_items):\n result[0] = np.log1p(len(recent_items)) / 8.0\n result[1] = np.log1p(len(np.unique(recent_items))) / 6.0\n result[2] = float(np.mean(event_features[start:, 0]))\n result[3] = float(np.mean(event_features[start:, 1]))\n result[4] = np.log1p(like_count) / 6.0\n result[5] = np.log1p(dislike_count) / 4.0\n return result\n\n\ndef prepare_data(\n public: Path, stage: str, config: Config\n) -> tuple[SequenceData, CandidateSet]:\n data = public / \"data\"\n catalog = pd.read_parquet(data / \"candidate_catalog.parquet\").sort_values(\n \"popularity_rank\"\n )\n item_ids = catalog.item_id.to_numpy(dtype=np.int64, copy=True)\n item_index = pd.Index(item_ids)\n n_items = len(item_ids)\n if n_items >= np.iinfo(np.uint16).max:\n raise RuntimeError(\"uint16 item storage is too small for this catalog\")\n\n if stage == \"validation\":\n prefixes = (\"train\",)\n history_end = base.TRAIN_END\n candidate_name = \"validation_eligible_candidates.npz\"\n state_name = \"validation_preference_state.parquet\"\n else:\n prefixes = (\"train\", \"val\")\n history_end = base.VALIDATION_END\n candidate_name = \"test_eligible_candidates.npz\"\n state_name = \"test_preference_state.parquet\"\n eligibility = CandidateSet.read(data / candidate_name)\n if not np.array_equal(eligibility.item_ids, item_ids):\n raise RuntimeError(\"catalog and candidate artifact item order differ\")\n\n explicit_by_user = base.load_explicit_events(data, prefixes, item_index)\n inference_likes, inference_dislikes, like_counts, dislike_counts = (\n base.load_inference_state(\n data / state_name,\n item_index,\n eligibility.uids,\n n_items,\n )\n )\n inference_rows = {int(uid): row for row, uid in enumerate(eligibility.uids)}\n inference_user_features = np.zeros((len(eligibility.uids), 6), dtype=np.float32)\n inference_sequence_rows = np.full(len(eligibility.uids), -1, dtype=np.int32)\n\n # Snapshot zero is the shared empty explicit state.\n liked_states = [np.full(base.LIKE_ITEMS, n_items, dtype=np.uint16)]\n disliked_states = [\n np.full(base.DISLIKE_ITEMS, n_items, dtype=np.uint16)\n ]\n state_counts: list[tuple[int, int]] = [(0, 0)]\n user_ids: list[int] = []\n offsets = [0]\n timestamp_parts: list[np.ndarray] = []\n item_parts: list[np.ndarray] = []\n event_parts: list[np.ndarray] = []\n activity_parts: list[np.ndarray] = []\n state_parts: list[np.ndarray] = []\n\n listen_paths = [data / f\"{prefix}_listens.parquet\" for prefix in prefixes]\n started = time.monotonic()\n for seen_users, (uid, listens) in enumerate(\n base.merge_listen_streams(listen_paths, item_index), start=1\n ):\n parts = build_sequence_parts(\n listens,\n explicit_by_user.get(uid, base.empty_explicit_events()),\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n if parts is None:\n continue\n sequence_row = len(user_ids)\n user_ids.append(uid)\n timestamp_parts.append(parts[0].astype(np.uint32, copy=False))\n item_parts.append(parts[1])\n event_parts.append(parts[2])\n activity_parts.append(parts[3])\n state_parts.append(parts[4])\n offsets.append(offsets[-1] + len(parts[1]))\n inference_row = inference_rows.get(uid)\n if inference_row is not None:\n inference_sequence_rows[inference_row] = sequence_row\n inference_user_features[inference_row] = final_user_features(\n parts[0],\n parts[1],\n parts[2],\n history_end,\n int(like_counts[inference_row]),\n int(dislike_counts[inference_row]),\n )\n if seen_users % 1_000 == 0:\n log(\n f\"prepared users={seen_users:,} tokens={offsets[-1]:,} \"\n f\"states={len(liked_states):,} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n missing = np.flatnonzero(inference_sequence_rows < 0)\n if len(missing):\n inference_user_features[missing, 4] = np.log1p(like_counts[missing]) / 6.0\n inference_user_features[missing, 5] = (\n np.log1p(dislike_counts[missing]) / 4.0\n )\n log(f\"target users without qualifying catalog sequence={len(missing):,}\")\n\n prepared = SequenceData(\n item_ids=item_ids,\n item_features=base.build_item_features(catalog),\n user_ids=np.asarray(user_ids, dtype=np.int64),\n offsets=np.asarray(offsets, dtype=np.int64),\n sequence_timestamps=np.concatenate(timestamp_parts),\n sequence_items=np.concatenate(item_parts),\n event_features=np.concatenate(event_parts),\n activity_features=np.concatenate(activity_parts),\n state_ids=np.concatenate(state_parts),\n liked_states=np.stack(liked_states),\n disliked_states=np.stack(disliked_states),\n state_counts=np.asarray(state_counts, dtype=np.int32),\n inference_user_rows=inference_sequence_rows,\n inference_liked_items=inference_likes,\n inference_disliked_items=inference_dislikes,\n inference_user_features=inference_user_features,\n )\n log(\n f\"prepared stage={stage} sequence_users={len(prepared.user_ids):,} \"\n f\"tokens={len(prepared.sequence_items):,} \"\n f\"next_item_targets={prepared.target_count:,} \"\n f\"states={len(prepared.liked_states):,}\"\n )\n return prepared, eligibility\n\n\ndef cache_metadata(stage: str, config: Config) -> dict[str, object]:\n return {\n \"cache_version\": CACHE_VERSION,\n \"stage\": stage,\n \"positive_listen_threshold\": base.LISTEN_POSITIVE_THRESHOLD,\n \"profile_lookback_days\": base.PROFILE_LOOKBACK_DAYS,\n \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n }\n\n\ndef save_cache(\n path: Path, prepared: SequenceData, stage: str, config: Config\n) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n np.savez(\n path,\n metadata=np.asarray(json.dumps(cache_metadata(stage, config), sort_keys=True)),\n **prepared.__dict__,\n )\n log(f\"saved public-derived full-sequence cache: {path}\")\n\n\ndef load_cache(path: Path, stage: str, config: Config) -> SequenceData:\n with np.load(path, allow_pickle=False) as artifact:\n actual = json.loads(str(artifact[\"metadata\"].item()))\n expected = cache_metadata(stage, config)\n if actual != expected:\n raise RuntimeError(f\"sequence cache differs: {actual} != {expected}\")\n values = {\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 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 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\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 generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 10_000)\n item_correction = build_item_correction(\n prepared,\n config,\n partition.window_starts,\n training_counts,\n )\n target_weights = build_target_weights(\n public,\n stage,\n prepared,\n partition.history_training_counts,\n config,\n )\n known_positive_packed = (\n build_known_positive_filter(prepared, partition.history_training_counts)\n if config.negative_filter_scope == \"full-training-history\"\n else None\n )\n history: list[dict[str, float]] = []\n best_loss = float(\"inf\")\n best_epoch = 0\n best_state: dict[str, torch.Tensor] | None = None\n epochs_without_improvement = 0\n start_epoch = 0\n\n if resume_checkpoint is not None:\n payload = torch.load(\n resume_checkpoint, map_location=device, weights_only=False\n )\n stored_config = payload.get(\"config\", {})\n architecture_fields = (\n \"bptt_steps\",\n \"embedding_dim\",\n \"hidden_dim\",\n \"user_hidden_1\",\n \"user_hidden_2\",\n \"item_hidden_1\",\n \"item_hidden_2\",\n )\n mismatches = {\n name: (stored_config.get(name), getattr(config, name))\n for name in architecture_fields\n if stored_config.get(name) != getattr(config, name)\n }\n if mismatches:\n raise RuntimeError(\n f\"resume checkpoint architecture differs: {mismatches}\"\n )\n stored_filter_scope = stored_config.get(\n \"negative_filter_scope\", \"last-100\"\n )\n stored_target_weighting = stored_config.get(\n \"target_weighting\", \"uniform\"\n )\n stored_weight_values = (\n stored_config.get(\"ordinary_target_weight\", 1.0),\n stored_config.get(\"organic_strong_target_weight\", 1.0),\n stored_config.get(\"organic_like_target_weight\", 3.0),\n stored_config.get(\"discovery_horizon_days\", 7),\n )\n current_weight_values = (\n config.ordinary_target_weight,\n config.organic_strong_target_weight,\n config.organic_like_target_weight,\n config.discovery_horizon_days,\n )\n stored_window_values = (\n stored_config.get(\"validation_fraction\", 0.10),\n stored_config.get(\"max_train_targets_per_user\", 0),\n stored_config.get(\"max_validation_targets_per_user\", 0),\n )\n current_window_values = (\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n objective_changed = (\n stored_filter_scope != config.negative_filter_scope\n or stored_target_weighting != config.target_weighting\n or stored_weight_values != current_weight_values\n or stored_window_values != current_window_values\n )\n model.load_state_dict(payload[\"model_state\"])\n if \"optimizer_state\" in payload and not objective_changed:\n optimizer.load_state_dict(payload[\"optimizer_state\"])\n for group in optimizer.param_groups:\n group[\"lr\"] = config.learning_rate\n group[\"weight_decay\"] = config.weight_decay\n log(f\"restored optimizer state from {resume_checkpoint}\")\n else:\n log(\n f\"using a fresh AdamW optimizer after loading {resume_checkpoint}\"\n )\n if \"generator_state\" in payload and not objective_changed:\n generator.set_state(payload[\"generator_state\"])\n start_epoch = int(payload[\"epoch\"])\n if start_epoch >= config.epochs:\n raise RuntimeError(\n f\"resume epoch {start_epoch} must be below --epochs {config.epochs}\"\n )\n prior = {\n str(name): float(value)\n for name, value in payload.get(\"diagnostics\", {}).items()\n }\n if objective_changed:\n prior.pop(\"validation_loss\", None)\n log(\n \"BCE objective changed: filter \"\n f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n \"target weighting \"\n f\"{stored_target_weighting}->{config.target_weighting}, \"\n f\"target window {stored_window_values}->{current_window_values}; \"\n \"recomputing the resume checkpoint's validation BCE\"\n )\n if \"validation_loss\" not in prior:\n prior.update(\n evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n )\n history.append(prior)\n best_loss = float(prior[\"validation_loss\"])\n best_epoch = start_epoch\n best_state = snapshot_model(model)\n log(\n f\"resumed validation-BCE training: epoch={start_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n\n for epoch in range(start_epoch + 1, config.epochs + 1):\n diagnostics = run_training_epoch(\n model,\n optimizer,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n item_correction,\n known_positive_packed,\n generator,\n device,\n epoch,\n )\n validation = evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n diagnostics.update(validation)\n history.append(diagnostics)\n current_state = snapshot_model(model)\n if checkpoint_dir is not None:\n save_epoch_checkpoint(\n checkpoint_dir,\n epoch,\n current_state,\n config,\n diagnostics,\n optimizer,\n generator,\n )\n improved = (\n diagnostics[\"validation_loss\"]\n < best_loss - config.early_stopping_min_delta\n )\n if improved:\n best_loss = diagnostics[\"validation_loss\"]\n best_epoch = epoch\n best_state = current_state\n epochs_without_improvement = 0\n if checkpoint_dir is not None:\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": current_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n checkpoint_dir / \"best.pt\",\n )\n else:\n epochs_without_improvement += 1\n log(\n f\"epoch={epoch}/{config.epochs} train_loss={diagnostics['loss']:.5f} \"\n f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n f\"pair_accuracy={diagnostics['pair_accuracy']:.3f} \"\n f\"train_targets={int(diagnostics['targets']):,} \"\n f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n f\"train_seconds={diagnostics['seconds']:.1f} \"\n f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n )\n release_accelerator_cache(device)\n if epochs_without_improvement >= config.early_stopping_patience:\n log(\n f\"early stopping after epoch={epoch}; \"\n f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n )\n break\n\n if best_state is None:\n raise RuntimeError(\"training did not produce a valid checkpoint\")\n model.load_state_dict(best_state)\n model.to(device).eval()\n log(\n f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n return model, history\n\n\[email protected]_mode()\ndef inference_queries(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n) -> torch.Tensor:\n sequence_rows = prepared.inference_user_rows\n lengths = np.where(\n sequence_rows >= 0, prepared.lengths[np.maximum(sequence_rows, 0)], 0\n )\n ordered = np.argsort(lengths, kind=\"stable\")\n result = torch.zeros(\n len(sequence_rows), config.embedding_dim, dtype=torch.float32\n )\n for group_start in range(0, len(ordered), config.user_batch_size):\n output_rows = ordered[group_start : group_start + config.user_batch_size]\n rows = sequence_rows[output_rows]\n group_lengths = lengths[output_rows]\n hidden = torch.zeros(\n 1, len(output_rows), config.embedding_dim, device=device\n )\n for start in range(0, int(group_lengths.max(initial=0)), config.bptt_steps):\n steps = min(\n config.bptt_steps,\n int(group_lengths.max(initial=0)) - start,\n )\n items = np.full(\n (len(output_rows), steps),\n len(prepared.item_ids),\n dtype=np.uint16,\n )\n features = np.zeros((len(output_rows), steps, 3), dtype=np.float16)\n counts = np.zeros(len(output_rows), dtype=np.int32)\n for local_row, sequence_row in enumerate(rows):\n if sequence_row < 0:\n continue\n count = min(steps, max(int(group_lengths[local_row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[sequence_row])\n selected = slice(offset + start, offset + start + count)\n items[local_row, :count] = prepared.sequence_items[selected]\n features[local_row, :count] = prepared.event_features[selected]\n counts[local_row] = count\n outputs, _ = event_sequence(\n model,\n torch.from_numpy(items.astype(np.int64)).to(device),\n torch.from_numpy(features.astype(np.float32)).to(device),\n hidden,\n )\n next_rows: list[torch.Tensor] = []\n for local_row, count in enumerate(counts):\n next_rows.append(\n outputs[local_row, count - 1]\n if count\n else hidden[0, local_row]\n )\n hidden = torch.stack(next_rows).unsqueeze(0)\n\n liked_items = torch.from_numpy(\n prepared.inference_liked_items[output_rows].astype(np.int64)\n ).to(device)\n disliked_items = torch.from_numpy(\n prepared.inference_disliked_items[output_rows].astype(np.int64)\n ).to(device)\n queries = user_queries(\n model,\n hidden.squeeze(0),\n model._mean_context_channel(liked_items),\n model._mean_context_channel(disliked_items),\n torch.from_numpy(\n prepared.inference_user_features[output_rows].astype(np.float32)\n ).to(device),\n )\n result[torch.from_numpy(output_rows.astype(np.int64))] = queries.cpu()\n return result\n\n\[email protected]_mode()\ndef rank_candidates(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n eligibility: CandidateSet,\n config: Config,\n device: torch.device,\n ranking_batch_size: int,\n) -> tuple[pd.DataFrame, dict[str, float]]:\n model.eval()\n queries = inference_queries(model, prepared, config, device)\n item_parts: list[torch.Tensor] = []\n for start in range(0, len(prepared.item_ids), 2_048):\n rows = torch.arange(\n start,\n min(start + 2_048, len(prepared.item_ids)),\n dtype=torch.long,\n device=device,\n )\n item_parts.append(model.encode_items(rows).cpu())\n items = torch.cat(item_parts)\n rows: list[tuple[int, int, int]] = []\n for start in range(0, len(eligibility.uids), ranking_batch_size):\n end = min(start + ranking_batch_size, len(eligibility.uids))\n scores = queries[start:end] @ items.T\n eligible = np.unpackbits(\n eligibility.packed[start:end],\n axis=1,\n count=len(prepared.item_ids),\n bitorder=\"little\",\n ).astype(bool, copy=False)\n scores.masked_fill_(~torch.from_numpy(eligible), -torch.inf)\n selected = torch.topk(scores, k=base.TOP_N, dim=1).indices.numpy()\n for local_row, columns in enumerate(selected):\n uid = int(eligibility.uids[start + local_row])\n rows.extend(\n (uid, int(prepared.item_ids[column]), rank)\n for rank, column in enumerate(columns, start=1)\n )\n if end == len(eligibility.uids) or end % 1_024 == 0:\n log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n query_norm = torch.linalg.vector_norm(queries, dim=1).numpy()\n item_norm = torch.linalg.vector_norm(items, dim=1).numpy()\n diagnostics = {\n \"query_norm_mean\": float(query_norm.mean()),\n \"query_norm_std\": float(query_norm.std()),\n \"item_norm_mean\": float(item_norm.mean()),\n \"item_norm_std\": float(item_norm.std()),\n }\n return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n\n\ndef write_ranking(frame: pd.DataFrame, output: Path) -> None:\n output.parent.mkdir(parents=True, exist_ok=True)\n if output.suffix == \".parquet\":\n frame.to_parquet(output, index=False, compression=\"zstd\")\n elif output.suffix == \".csv\":\n frame.to_csv(output, index=False)\n else:\n raise ValueError(\"--output must end in .parquet or .csv\")\n\n",
"stderr": "",
"aggregated_output": " changed = True\n if changed:\n current_state = append_state_snapshot(\n active_likes,\n active_dislikes,\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n states[row] = np.uint32(current_state)\n return timestamps, items.copy(), event_features, activity, states\n\n\ndef final_user_features(\n timestamps: np.ndarray,\n items: np.ndarray,\n event_features: np.ndarray,\n history_end: int,\n like_count: int,\n dislike_count: int,\n) -> np.ndarray:\n start = int(\n np.searchsorted(\n timestamps,\n history_end - base.PROFILE_LOOKBACK_DAYS * base.DAY,\n side=\"left\",\n )\n )\n result = np.zeros(6, dtype=np.float32)\n recent_items = items[start:]\n if len(recent_items):\n result[0] = np.log1p(len(recent_items)) / 8.0\n result[1] = np.log1p(len(np.unique(recent_items))) / 6.0\n result[2] = float(np.mean(event_features[start:, 0]))\n result[3] = float(np.mean(event_features[start:, 1]))\n result[4] = np.log1p(like_count) / 6.0\n result[5] = np.log1p(dislike_count) / 4.0\n return result\n\n\ndef prepare_data(\n public: Path, stage: str, config: Config\n) -> tuple[SequenceData, CandidateSet]:\n data = public / \"data\"\n catalog = pd.read_parquet(data / \"candidate_catalog.parquet\").sort_values(\n \"popularity_rank\"\n )\n item_ids = catalog.item_id.to_numpy(dtype=np.int64, copy=True)\n item_index = pd.Index(item_ids)\n n_items = len(item_ids)\n if n_items >= np.iinfo(np.uint16).max:\n raise RuntimeError(\"uint16 item storage is too small for this catalog\")\n\n if stage == \"validation\":\n prefixes = (\"train\",)\n history_end = base.TRAIN_END\n candidate_name = \"validation_eligible_candidates.npz\"\n state_name = \"validation_preference_state.parquet\"\n else:\n prefixes = (\"train\", \"val\")\n history_end = base.VALIDATION_END\n candidate_name = \"test_eligible_candidates.npz\"\n state_name = \"test_preference_state.parquet\"\n eligibility = CandidateSet.read(data / candidate_name)\n if not np.array_equal(eligibility.item_ids, item_ids):\n raise RuntimeError(\"catalog and candidate artifact item order differ\")\n\n explicit_by_user = base.load_explicit_events(data, prefixes, item_index)\n inference_likes, inference_dislikes, like_counts, dislike_counts = (\n base.load_inference_state(\n data / state_name,\n item_index,\n eligibility.uids,\n n_items,\n )\n )\n inference_rows = {int(uid): row for row, uid in enumerate(eligibility.uids)}\n inference_user_features = np.zeros((len(eligibility.uids), 6), dtype=np.float32)\n inference_sequence_rows = np.full(len(eligibility.uids), -1, dtype=np.int32)\n\n # Snapshot zero is the shared empty explicit state.\n liked_states = [np.full(base.LIKE_ITEMS, n_items, dtype=np.uint16)]\n disliked_states = [\n np.full(base.DISLIKE_ITEMS, n_items, dtype=np.uint16)\n ]\n state_counts: list[tuple[int, int]] = [(0, 0)]\n user_ids: list[int] = []\n offsets = [0]\n timestamp_parts: list[np.ndarray] = []\n item_parts: list[np.ndarray] = []\n event_parts: list[np.ndarray] = []\n activity_parts: list[np.ndarray] = []\n state_parts: list[np.ndarray] = []\n\n listen_paths = [data / f\"{prefix}_listens.parquet\" for prefix in prefixes]\n started = time.monotonic()\n for seen_users, (uid, listens) in enumerate(\n base.merge_listen_streams(listen_paths, item_index), start=1\n ):\n parts = build_sequence_parts(\n listens,\n explicit_by_user.get(uid, base.empty_explicit_events()),\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n if parts is None:\n continue\n sequence_row = len(user_ids)\n user_ids.append(uid)\n timestamp_parts.append(parts[0].astype(np.uint32, copy=False))\n item_parts.append(parts[1])\n event_parts.append(parts[2])\n activity_parts.append(parts[3])\n state_parts.append(parts[4])\n offsets.append(offsets[-1] + len(parts[1]))\n inference_row = inference_rows.get(uid)\n if inference_row is not None:\n inference_sequence_rows[inference_row] = sequence_row\n inference_user_features[inference_row] = final_user_features(\n parts[0],\n parts[1],\n parts[2],\n history_end,\n int(like_counts[inference_row]),\n int(dislike_counts[inference_row]),\n )\n if seen_users % 1_000 == 0:\n log(\n f\"prepared users={seen_users:,} tokens={offsets[-1]:,} \"\n f\"states={len(liked_states):,} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n missing = np.flatnonzero(inference_sequence_rows < 0)\n if len(missing):\n inference_user_features[missing, 4] = np.log1p(like_counts[missing]) / 6.0\n inference_user_features[missing, 5] = (\n np.log1p(dislike_counts[missing]) / 4.0\n )\n log(f\"target users without qualifying catalog sequence={len(missing):,}\")\n\n prepared = SequenceData(\n item_ids=item_ids,\n item_features=base.build_item_features(catalog),\n user_ids=np.asarray(user_ids, dtype=np.int64),\n offsets=np.asarray(offsets, dtype=np.int64),\n sequence_timestamps=np.concatenate(timestamp_parts),\n sequence_items=np.concatenate(item_parts),\n event_features=np.concatenate(event_parts),\n activity_features=np.concatenate(activity_parts),\n state_ids=np.concatenate(state_parts),\n liked_states=np.stack(liked_states),\n disliked_states=np.stack(disliked_states),\n state_counts=np.asarray(state_counts, dtype=np.int32),\n inference_user_rows=inference_sequence_rows,\n inference_liked_items=inference_likes,\n inference_disliked_items=inference_dislikes,\n inference_user_features=inference_user_features,\n )\n log(\n f\"prepared stage={stage} sequence_users={len(prepared.user_ids):,} \"\n f\"tokens={len(prepared.sequence_items):,} \"\n f\"next_item_targets={prepared.target_count:,} \"\n f\"states={len(prepared.liked_states):,}\"\n )\n return prepared, eligibility\n\n\ndef cache_metadata(stage: str, config: Config) -> dict[str, object]:\n return {\n \"cache_version\": CACHE_VERSION,\n \"stage\": stage,\n \"positive_listen_threshold\": base.LISTEN_POSITIVE_THRESHOLD,\n \"profile_lookback_days\": base.PROFILE_LOOKBACK_DAYS,\n \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n }\n\n\ndef save_cache(\n path: Path, prepared: SequenceData, stage: str, config: Config\n) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n np.savez(\n path,\n metadata=np.asarray(json.dumps(cache_metadata(stage, config), sort_keys=True)),\n **prepared.__dict__,\n )\n log(f\"saved public-derived full-sequence cache: {path}\")\n\n\ndef load_cache(path: Path, stage: str, config: Config) -> SequenceData:\n with np.load(path, allow_pickle=False) as artifact:\n actual = json.loads(str(artifact[\"metadata\"].item()))\n expected = cache_metadata(stage, config)\n if actual != expected:\n raise RuntimeError(f\"sequence cache differs: {actual} != {expected}\")\n values = {\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 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 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\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 generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 10_000)\n item_correction = build_item_correction(\n prepared,\n config,\n partition.window_starts,\n training_counts,\n )\n target_weights = build_target_weights(\n public,\n stage,\n prepared,\n partition.history_training_counts,\n config,\n )\n known_positive_packed = (\n build_known_positive_filter(prepared, partition.history_training_counts)\n if config.negative_filter_scope == \"full-training-history\"\n else None\n )\n history: list[dict[str, float]] = []\n best_loss = float(\"inf\")\n best_epoch = 0\n best_state: dict[str, torch.Tensor] | None = None\n epochs_without_improvement = 0\n start_epoch = 0\n\n if resume_checkpoint is not None:\n payload = torch.load(\n resume_checkpoint, map_location=device, weights_only=False\n )\n stored_config = payload.get(\"config\", {})\n architecture_fields = (\n \"bptt_steps\",\n \"embedding_dim\",\n \"hidden_dim\",\n \"user_hidden_1\",\n \"user_hidden_2\",\n \"item_hidden_1\",\n \"item_hidden_2\",\n )\n mismatches = {\n name: (stored_config.get(name), getattr(config, name))\n for name in architecture_fields\n if stored_config.get(name) != getattr(config, name)\n }\n if mismatches:\n raise RuntimeError(\n f\"resume checkpoint architecture differs: {mismatches}\"\n )\n stored_filter_scope = stored_config.get(\n \"negative_filter_scope\", \"last-100\"\n )\n stored_target_weighting = stored_config.get(\n \"target_weighting\", \"uniform\"\n )\n stored_weight_values = (\n stored_config.get(\"ordinary_target_weight\", 1.0),\n stored_config.get(\"organic_strong_target_weight\", 1.0),\n stored_config.get(\"organic_like_target_weight\", 3.0),\n stored_config.get(\"discovery_horizon_days\", 7),\n )\n current_weight_values = (\n config.ordinary_target_weight,\n config.organic_strong_target_weight,\n config.organic_like_target_weight,\n config.discovery_horizon_days,\n )\n stored_window_values = (\n stored_config.get(\"validation_fraction\", 0.10),\n stored_config.get(\"max_train_targets_per_user\", 0),\n stored_config.get(\"max_validation_targets_per_user\", 0),\n )\n current_window_values = (\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n objective_changed = (\n stored_filter_scope != config.negative_filter_scope\n or stored_target_weighting != config.target_weighting\n or stored_weight_values != current_weight_values\n or stored_window_values != current_window_values\n )\n model.load_state_dict(payload[\"model_state\"])\n if \"optimizer_state\" in payload and not objective_changed:\n optimizer.load_state_dict(payload[\"optimizer_state\"])\n for group in optimizer.param_groups:\n group[\"lr\"] = config.learning_rate\n group[\"weight_decay\"] = config.weight_decay\n log(f\"restored optimizer state from {resume_checkpoint}\")\n else:\n log(\n f\"using a fresh AdamW optimizer after loading {resume_checkpoint}\"\n )\n if \"generator_state\" in payload and not objective_changed:\n generator.set_state(payload[\"generator_state\"])\n start_epoch = int(payload[\"epoch\"])\n if start_epoch >= config.epochs:\n raise RuntimeError(\n f\"resume epoch {start_epoch} must be below --epochs {config.epochs}\"\n )\n prior = {\n str(name): float(value)\n for name, value in payload.get(\"diagnostics\", {}).items()\n }\n if objective_changed:\n prior.pop(\"validation_loss\", None)\n log(\n \"BCE objective changed: filter \"\n f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n \"target weighting \"\n f\"{stored_target_weighting}->{config.target_weighting}, \"\n f\"target window {stored_window_values}->{current_window_values}; \"\n \"recomputing the resume checkpoint's validation BCE\"\n )\n if \"validation_loss\" not in prior:\n prior.update(\n evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n )\n history.append(prior)\n best_loss = float(prior[\"validation_loss\"])\n best_epoch = start_epoch\n best_state = snapshot_model(model)\n log(\n f\"resumed validation-BCE training: epoch={start_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n\n for epoch in range(start_epoch + 1, config.epochs + 1):\n diagnostics = run_training_epoch(\n model,\n optimizer,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n item_correction,\n known_positive_packed,\n generator,\n device,\n epoch,\n )\n validation = evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n diagnostics.update(validation)\n history.append(diagnostics)\n current_state = snapshot_model(model)\n if checkpoint_dir is not None:\n save_epoch_checkpoint(\n checkpoint_dir,\n epoch,\n current_state,\n config,\n diagnostics,\n optimizer,\n generator,\n )\n improved = (\n diagnostics[\"validation_loss\"]\n < best_loss - config.early_stopping_min_delta\n )\n if improved:\n best_loss = diagnostics[\"validation_loss\"]\n best_epoch = epoch\n best_state = current_state\n epochs_without_improvement = 0\n if checkpoint_dir is not None:\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": current_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n checkpoint_dir / \"best.pt\",\n )\n else:\n epochs_without_improvement += 1\n log(\n f\"epoch={epoch}/{config.epochs} train_loss={diagnostics['loss']:.5f} \"\n f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n f\"pair_accuracy={diagnostics['pair_accuracy']:.3f} \"\n f\"train_targets={int(diagnostics['targets']):,} \"\n f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n f\"train_seconds={diagnostics['seconds']:.1f} \"\n f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n )\n release_accelerator_cache(device)\n if epochs_without_improvement >= config.early_stopping_patience:\n log(\n f\"early stopping after epoch={epoch}; \"\n f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n )\n break\n\n if best_state is None:\n raise RuntimeError(\"training did not produce a valid checkpoint\")\n model.load_state_dict(best_state)\n model.to(device).eval()\n log(\n f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n return model, history\n\n\[email protected]_mode()\ndef inference_queries(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n) -> torch.Tensor:\n sequence_rows = prepared.inference_user_rows\n lengths = np.where(\n sequence_rows >= 0, prepared.lengths[np.maximum(sequence_rows, 0)], 0\n )\n ordered = np.argsort(lengths, kind=\"stable\")\n result = torch.zeros(\n len(sequence_rows), config.embedding_dim, dtype=torch.float32\n )\n for group_start in range(0, len(ordered), config.user_batch_size):\n output_rows = ordered[group_start : group_start + config.user_batch_size]\n rows = sequence_rows[output_rows]\n group_lengths = lengths[output_rows]\n hidden = torch.zeros(\n 1, len(output_rows), config.embedding_dim, device=device\n )\n for start in range(0, int(group_lengths.max(initial=0)), config.bptt_steps):\n steps = min(\n config.bptt_steps,\n int(group_lengths.max(initial=0)) - start,\n )\n items = np.full(\n (len(output_rows), steps),\n len(prepared.item_ids),\n dtype=np.uint16,\n )\n features = np.zeros((len(output_rows), steps, 3), dtype=np.float16)\n counts = np.zeros(len(output_rows), dtype=np.int32)\n for local_row, sequence_row in enumerate(rows):\n if sequence_row < 0:\n continue\n count = min(steps, max(int(group_lengths[local_row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[sequence_row])\n selected = slice(offset + start, offset + start + count)\n items[local_row, :count] = prepared.sequence_items[selected]\n features[local_row, :count] = prepared.event_features[selected]\n counts[local_row] = count\n outputs, _ = event_sequence(\n model,\n torch.from_numpy(items.astype(np.int64)).to(device),\n torch.from_numpy(features.astype(np.float32)).to(device),\n hidden,\n )\n next_rows: list[torch.Tensor] = []\n for local_row, count in enumerate(counts):\n next_rows.append(\n outputs[local_row, count - 1]\n if count\n else hidden[0, local_row]\n )\n hidden = torch.stack(next_rows).unsqueeze(0)\n\n liked_items = torch.from_numpy(\n prepared.inference_liked_items[output_rows].astype(np.int64)\n ).to(device)\n disliked_items = torch.from_numpy(\n prepared.inference_disliked_items[output_rows].astype(np.int64)\n ).to(device)\n queries = user_queries(\n model,\n hidden.squeeze(0),\n model._mean_context_channel(liked_items),\n model._mean_context_channel(disliked_items),\n torch.from_numpy(\n prepared.inference_user_features[output_rows].astype(np.float32)\n ).to(device),\n )\n result[torch.from_numpy(output_rows.astype(np.int64))] = queries.cpu()\n return result\n\n\[email protected]_mode()\ndef rank_candidates(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n eligibility: CandidateSet,\n config: Config,\n device: torch.device,\n ranking_batch_size: int,\n) -> tuple[pd.DataFrame, dict[str, float]]:\n model.eval()\n queries = inference_queries(model, prepared, config, device)\n item_parts: list[torch.Tensor] = []\n for start in range(0, len(prepared.item_ids), 2_048):\n rows = torch.arange(\n start,\n min(start + 2_048, len(prepared.item_ids)),\n dtype=torch.long,\n device=device,\n )\n item_parts.append(model.encode_items(rows).cpu())\n items = torch.cat(item_parts)\n rows: list[tuple[int, int, int]] = []\n for start in range(0, len(eligibility.uids), ranking_batch_size):\n end = min(start + ranking_batch_size, len(eligibility.uids))\n scores = queries[start:end] @ items.T\n eligible = np.unpackbits(\n eligibility.packed[start:end],\n axis=1,\n count=len(prepared.item_ids),\n bitorder=\"little\",\n ).astype(bool, copy=False)\n scores.masked_fill_(~torch.from_numpy(eligible), -torch.inf)\n selected = torch.topk(scores, k=base.TOP_N, dim=1).indices.numpy()\n for local_row, columns in enumerate(selected):\n uid = int(eligibility.uids[start + local_row])\n rows.extend(\n (uid, int(prepared.item_ids[column]), rank)\n for rank, column in enumerate(columns, start=1)\n )\n if end == len(eligibility.uids) or end % 1_024 == 0:\n log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n query_norm = torch.linalg.vector_norm(queries, dim=1).numpy()\n item_norm = torch.linalg.vector_norm(items, dim=1).numpy()\n diagnostics = {\n \"query_norm_mean\": float(query_norm.mean()),\n \"query_norm_std\": float(query_norm.std()),\n \"item_norm_mean\": float(item_norm.mean()),\n \"item_norm_std\": float(item_norm.std()),\n }\n return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n\n\ndef write_ranking(frame: pd.DataFrame, output: Path) -> None:\n output.parent.mkdir(parents=True, exist_ok=True)\n if output.suffix == \".parquet\":\n frame.to_parquet(output, index=False, compression=\"zstd\")\n elif output.suffix == \".csv\":\n frame.to_csv(output, index=False)\n else:\n raise ValueError(\"--output must end in .parquet or .csv\")\n\n",
"exit_code": 0,
"duration": {
"secs": 0,
"nanos": 3371
},
"formatted_output": "Warning: truncated output (original token count: 11358)\nTotal output lines: 1213\n\n changed = True\n if changed:\n current_state = append_state_snapshot(\n active_likes,\n active_dislikes,\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n states[row] = np.uint32(current_state)\n return timestamps, items.copy(), event_features, activity, states\n\n\ndef final_user_features(\n timestamps: np.ndarray,\n items: np.ndarray,\n event_features: np.ndarray,\n history_end: int,\n like_count: int,\n dislike_count: int,\n) -> np.ndarray:\n start = int(\n np.searchsorted(\n timestamps,\n history_end - base.PROFILE_LOOKBACK_DAYS * base.DAY,\n side=\"left\",\n )\n )\n result = np.zeros(6, dtype=np.float32)\n recent_items = items[start:]\n if len(recent_items):\n result[0] = np.log1p(len(recent_items)) / 8.0\n result[1] = np.log1p(len(np.unique(recent_items))) / 6.0\n result[2] = float(np.mean(event_features[start:, 0]))\n result[3] = float(np.mean(event_features[start:, 1]))\n result[4] = np.log1p(like_count) / 6.0\n result[5] = np.log1p(dislike_count) / 4.0\n return result\n\n\ndef prepare_data(\n public: Path, stage: str, config: Config\n) -> tuple[SequenceData, CandidateSet]:\n data = public / \"data\"\n catalog = pd.read_parquet(data / \"candidate_catalog.parquet\").sort_values(\n \"popularity_rank\"\n )\n item_ids = catalog.item_id.to_numpy(dtype=np.int64, copy=True)\n item_index = pd.Index(item_ids)\n n_items = len(item_ids)\n if n_items >= np.iinfo(np.uint16).max:\n raise RuntimeError(\"uint16 item storage is too small for this catalog\")\n\n if stage == \"validation\":\n prefixes = (\"train\",)\n history_end = base.TRAIN_END\n candidate_name = \"validation_eligible_candidates.npz\"\n state_name = \"validation_preference_state.parquet\"\n else:\n prefixes = (\"train\", \"val\")\n history_end = base.VALIDATION_END\n candidate_name = \"test_eligible_candidates.npz\"\n state_name = \"test_preference_state.parquet\"\n eligibility = CandidateSet.read(data / candidate_name)\n if not np.array_equal(eligibility.item_ids, item_ids):\n raise RuntimeError(\"catalog and candidate artifact item order differ\")\n\n explicit_by_user = base.load_explicit_events(data, prefixes, item_index)\n inference_likes, inference_dislikes, like_counts, dislike_counts = (\n base.load_inference_state(\n data / state_name,\n item_index,\n eligibility.uids,\n n_items,\n )\n )\n inference_rows = {int(uid): row for row, uid in enumerate(eligibility.uids)}\n inference_user_features = np.zeros((len(eligibility.uids), 6), dtype=np.float32)\n inference_sequence_rows = np.full(len(eligibility.uids), -1, dtype=np.int32)\n\n # Snapshot zero is the shared empty explicit state.\n liked_states = [np.full(base.LIKE_ITEMS, n_items, dtype=np.uint16)]\n disliked_states = [\n np.full(base.DISLIKE_ITEMS, n_items, dtype=np.uint16)\n ]\n state_counts: list[tuple[int, int]] = [(0, 0)]\n user_ids: list[int] = []\n offsets = [0]\n timestamp_parts: list[np.ndarray] = []\n item_parts: list[np.ndarray] = []\n event_parts: list[np.ndarray] = []\n activity_parts: list[np.ndarray] = []\n state_parts: list[np.ndarray] = []\n\n listen_paths = [data / f\"{prefix}_listens.parquet\" for prefix in prefixes]\n started = time.monotonic()\n for seen_users, (uid, listens) in enumerate(\n base.merge_listen_streams(listen_paths, item_index), start=1\n ):\n parts = build_sequence_parts(\n listens,\n explicit_by_user.get(uid, base.empty_explicit_events()),\n n_items,\n liked_states,\n disliked_states,\n state_counts,\n )\n if parts is None:\n continue\n sequence_row = len(user_ids)\n user_ids.append(uid)\n timestamp_parts.append(parts[0].astype(np.uint32, copy=False))\n item_parts.append(parts[1])\n event_parts.append(parts[2])\n activity_parts.append(parts[3])\n state_parts.append(parts[4])\n offsets.append(offsets[-1] + len(parts[1]))\n inference_row = inference_rows.get(uid)\n if inference_row is not None:\n inference_sequence_rows[inference_row] = sequence_row\n inference_user_features[inference_row] = final_user_features(\n parts[0],\n parts[1],\n parts[2],\n history_end,\n int(like_counts[inference_row]),\n int(dislike_counts[inference_row]),\n )\n if seen_users % 1_000 == 0:\n log(\n f\"prepared users={seen_users:,} tokens={offsets[-1]:,} \"\n f\"states={len(liked_states):,} \"\n f\"elapsed={time.monotonic() - started:.1f}s\"\n )\n\n missing = np.flatnonzero(inference_sequence_rows < 0)\n if len(missing):\n inference_user_features[missing, 4] = np.log1p(like_counts[missing]) / 6.0\n inference_user_features[missing, 5] = (\n np.log1p(dislike_counts[missing]) / 4.0\n )\n log(f\"target users without qualifying catalog sequence={len(missing):,}\")\n\n prepared = SequenceData(\n item_ids=item_ids,\n item_features=base.build_item_features(catalog),\n user_ids=np.asarray(user_ids, dtype=np.int64),\n offsets=np.asarray(offsets, dtype=np.int64),\n sequence_timestamps=np.concatenate(timestamp_parts),\n sequence_items=np.concatenate(item_parts),\n event_features=np.concatenate(event_parts),\n activity_features=np.concatenate(activity_parts),\n state_ids=np.concatenate(state_parts),\n liked_states=np.stack(liked_states),\n disliked_states=np.stack(disliked_states),\n state_counts=np.asarray(state_counts, dtype=np.int32),\n inference_user_rows=inference_sequence_rows,\n inference_liked_items=inference_likes,\n inference_disliked_items=inference_dislikes,\n inference_user_features=inference_user_features,\n )\n log(\n f\"prepared stage={stage} sequence_users={len(prepared.user_ids):,} \"\n f\"tokens={len(prepared.sequence_items):,} \"\n f\"next_item_targets={prepared.target_count:,} \"\n f\"states={len(prepared.liked_states):,}\"\n )\n return prepared, eligibility\n\n\ndef cache_metadata(stage: str, config: Config) -> dict[str, object]:\n return {\n \"cache_version\": CACHE_VERSION,\n \"stage\": stage,\n \"positive_listen_threshold\": base.LISTEN_POSITIVE_THRESHOLD,\n \"profile_lookback_days\": base.PROFILE_LOOKBACK_DAYS,\n \"event_time_feature\": \"exp-negative-gap-over-60-days\",\n }\n\n\ndef save_cache(\n path: Path, prepared: SequenceData, stage: str, config: Config\n) -> None:\n path.parent.mkdir(parents=True, exist_ok=True)\n np.savez(\n path,\n metadata=np.asarray(json.dumps(cache_metadata(stage, config), sort_keys=True)),\n **prepared.__dict__,\n )\n log(f\"saved public-derived full-sequence cache: {path}\")\n\n\ndef load_cache(path: Path, stage: str, config: Config) -> SequenceData:\n with np.load(path, allow_pickle=False) as artifact:\n actual = json.loads(str(artifact[\"metadata\"].item()))\n expected = cache_metadata(stage, config)\n if actual != expected:\n raise RuntimeError(f\"sequence cache differs: {actual} != {expected}\")\n values = {\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 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 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 …1358 tokens truncated…nge(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\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 generator = torch.Generator(device=\"cpu\").manual_seed(config.seed + 10_000)\n item_correction = build_item_correction(\n prepared,\n config,\n partition.window_starts,\n training_counts,\n )\n target_weights = build_target_weights(\n public,\n stage,\n prepared,\n partition.history_training_counts,\n config,\n )\n known_positive_packed = (\n build_known_positive_filter(prepared, partition.history_training_counts)\n if config.negative_filter_scope == \"full-training-history\"\n else None\n )\n history: list[dict[str, float]] = []\n best_loss = float(\"inf\")\n best_epoch = 0\n best_state: dict[str, torch.Tensor] | None = None\n epochs_without_improvement = 0\n start_epoch = 0\n\n if resume_checkpoint is not None:\n payload = torch.load(\n resume_checkpoint, map_location=device, weights_only=False\n )\n stored_config = payload.get(\"config\", {})\n architecture_fields = (\n \"bptt_steps\",\n \"embedding_dim\",\n \"hidden_dim\",\n \"user_hidden_1\",\n \"user_hidden_2\",\n \"item_hidden_1\",\n \"item_hidden_2\",\n )\n mismatches = {\n name: (stored_config.get(name), getattr(config, name))\n for name in architecture_fields\n if stored_config.get(name) != getattr(config, name)\n }\n if mismatches:\n raise RuntimeError(\n f\"resume checkpoint architecture differs: {mismatches}\"\n )\n stored_filter_scope = stored_config.get(\n \"negative_filter_scope\", \"last-100\"\n )\n stored_target_weighting = stored_config.get(\n \"target_weighting\", \"uniform\"\n )\n stored_weight_values = (\n stored_config.get(\"ordinary_target_weight\", 1.0),\n stored_config.get(\"organic_strong_target_weight\", 1.0),\n stored_config.get(\"organic_like_target_weight\", 3.0),\n stored_config.get(\"discovery_horizon_days\", 7),\n )\n current_weight_values = (\n config.ordinary_target_weight,\n config.organic_strong_target_weight,\n config.organic_like_target_weight,\n config.discovery_horizon_days,\n )\n stored_window_values = (\n stored_config.get(\"validation_fraction\", 0.10),\n stored_config.get(\"max_train_targets_per_user\", 0),\n stored_config.get(\"max_validation_targets_per_user\", 0),\n )\n current_window_values = (\n config.validation_fraction,\n config.max_train_targets_per_user,\n config.max_validation_targets_per_user,\n )\n objective_changed = (\n stored_filter_scope != config.negative_filter_scope\n or stored_target_weighting != config.target_weighting\n or stored_weight_values != current_weight_values\n or stored_window_values != current_window_values\n )\n model.load_state_dict(payload[\"model_state\"])\n if \"optimizer_state\" in payload and not objective_changed:\n optimizer.load_state_dict(payload[\"optimizer_state\"])\n for group in optimizer.param_groups:\n group[\"lr\"] = config.learning_rate\n group[\"weight_decay\"] = config.weight_decay\n log(f\"restored optimizer state from {resume_checkpoint}\")\n else:\n log(\n f\"using a fresh AdamW optimizer after loading {resume_checkpoint}\"\n )\n if \"generator_state\" in payload and not objective_changed:\n generator.set_state(payload[\"generator_state\"])\n start_epoch = int(payload[\"epoch\"])\n if start_epoch >= config.epochs:\n raise RuntimeError(\n f\"resume epoch {start_epoch} must be below --epochs {config.epochs}\"\n )\n prior = {\n str(name): float(value)\n for name, value in payload.get(\"diagnostics\", {}).items()\n }\n if objective_changed:\n prior.pop(\"validation_loss\", None)\n log(\n \"BCE objective changed: filter \"\n f\"{stored_filter_scope}->{config.negative_filter_scope}, \"\n \"target weighting \"\n f\"{stored_target_weighting}->{config.target_weighting}, \"\n f\"target window {stored_window_values}->{current_window_values}; \"\n \"recomputing the resume checkpoint's validation BCE\"\n )\n if \"validation_loss\" not in prior:\n prior.update(\n evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n )\n history.append(prior)\n best_loss = float(prior[\"validation_loss\"])\n best_epoch = start_epoch\n best_state = snapshot_model(model)\n log(\n f\"resumed validation-BCE training: epoch={start_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n\n for epoch in range(start_epoch + 1, config.epochs + 1):\n diagnostics = run_training_epoch(\n model,\n optimizer,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n item_correction,\n known_positive_packed,\n generator,\n device,\n epoch,\n )\n validation = evaluate_validation_bce(\n model,\n prepared,\n config,\n target_weights,\n partition.window_starts,\n training_counts,\n validation_counts,\n item_correction,\n known_positive_packed,\n device,\n )\n diagnostics.update(validation)\n history.append(diagnostics)\n current_state = snapshot_model(model)\n if checkpoint_dir is not None:\n save_epoch_checkpoint(\n checkpoint_dir,\n epoch,\n current_state,\n config,\n diagnostics,\n optimizer,\n generator,\n )\n improved = (\n diagnostics[\"validation_loss\"]\n < best_loss - config.early_stopping_min_delta\n )\n if improved:\n best_loss = diagnostics[\"validation_loss\"]\n best_epoch = epoch\n best_state = current_state\n epochs_without_improvement = 0\n if checkpoint_dir is not None:\n torch.save(\n {\n \"epoch\": epoch,\n \"config\": asdict(config),\n \"diagnostics\": diagnostics,\n \"model_state\": current_state,\n \"optimizer_state\": optimizer.state_dict(),\n \"generator_state\": generator.get_state(),\n },\n checkpoint_dir / \"best.pt\",\n )\n else:\n epochs_without_improvement += 1\n log(\n f\"epoch={epoch}/{config.epochs} train_loss={diagnostics['loss']:.5f} \"\n f\"validation_loss={diagnostics['validation_loss']:.5f} \"\n f\"pair_accuracy={diagnostics['pair_accuracy']:.3f} \"\n f\"train_targets={int(diagnostics['targets']):,} \"\n f\"validation_targets={int(diagnostics['validation_targets']):,} \"\n f\"train_seconds={diagnostics['seconds']:.1f} \"\n f\"validation_seconds={diagnostics['validation_seconds']:.1f}\"\n )\n release_accelerator_cache(device)\n if epochs_without_improvement >= config.early_stopping_patience:\n log(\n f\"early stopping after epoch={epoch}; \"\n f\"best_epoch={best_epoch} best_validation_loss={best_loss:.6f}\"\n )\n break\n\n if best_state is None:\n raise RuntimeError(\"training did not produce a valid checkpoint\")\n model.load_state_dict(best_state)\n model.to(device).eval()\n log(\n f\"restored best validation-BCE checkpoint: epoch={best_epoch} \"\n f\"loss={best_loss:.6f}\"\n )\n return model, history\n\n\[email protected]_mode()\ndef inference_queries(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n config: Config,\n device: torch.device,\n) -> torch.Tensor:\n sequence_rows = prepared.inference_user_rows\n lengths = np.where(\n sequence_rows >= 0, prepared.lengths[np.maximum(sequence_rows, 0)], 0\n )\n ordered = np.argsort(lengths, kind=\"stable\")\n result = torch.zeros(\n len(sequence_rows), config.embedding_dim, dtype=torch.float32\n )\n for group_start in range(0, len(ordered), config.user_batch_size):\n output_rows = ordered[group_start : group_start + config.user_batch_size]\n rows = sequence_rows[output_rows]\n group_lengths = lengths[output_rows]\n hidden = torch.zeros(\n 1, len(output_rows), config.embedding_dim, device=device\n )\n for start in range(0, int(group_lengths.max(initial=0)), config.bptt_steps):\n steps = min(\n config.bptt_steps,\n int(group_lengths.max(initial=0)) - start,\n )\n items = np.full(\n (len(output_rows), steps),\n len(prepared.item_ids),\n dtype=np.uint16,\n )\n features = np.zeros((len(output_rows), steps, 3), dtype=np.float16)\n counts = np.zeros(len(output_rows), dtype=np.int32)\n for local_row, sequence_row in enumerate(rows):\n if sequence_row < 0:\n continue\n count = min(steps, max(int(group_lengths[local_row]) - start, 0))\n if not count:\n continue\n offset = int(prepared.offsets[sequence_row])\n selected = slice(offset + start, offset + start + count)\n items[local_row, :count] = prepared.sequence_items[selected]\n features[local_row, :count] = prepared.event_features[selected]\n counts[local_row] = count\n outputs, _ = event_sequence(\n model,\n torch.from_numpy(items.astype(np.int64)).to(device),\n torch.from_numpy(features.astype(np.float32)).to(device),\n hidden,\n )\n next_rows: list[torch.Tensor] = []\n for local_row, count in enumerate(counts):\n next_rows.append(\n outputs[local_row, count - 1]\n if count\n else hidden[0, local_row]\n )\n hidden = torch.stack(next_rows).unsqueeze(0)\n\n liked_items = torch.from_numpy(\n prepared.inference_liked_items[output_rows].astype(np.int64)\n ).to(device)\n disliked_items = torch.from_numpy(\n prepared.inference_disliked_items[output_rows].astype(np.int64)\n ).to(device)\n queries = user_queries(\n model,\n hidden.squeeze(0),\n model._mean_context_channel(liked_items),\n model._mean_context_channel(disliked_items),\n torch.from_numpy(\n prepared.inference_user_features[output_rows].astype(np.float32)\n ).to(device),\n )\n result[torch.from_numpy(output_rows.astype(np.int64))] = queries.cpu()\n return result\n\n\[email protected]_mode()\ndef rank_candidates(\n model: base.SequentialTwoTower,\n prepared: SequenceData,\n eligibility: CandidateSet,\n config: Config,\n device: torch.device,\n ranking_batch_size: int,\n) -> tuple[pd.DataFrame, dict[str, float]]:\n model.eval()\n queries = inference_queries(model, prepared, config, device)\n item_parts: list[torch.Tensor] = []\n for start in range(0, len(prepared.item_ids), 2_048):\n rows = torch.arange(\n start,\n min(start + 2_048, len(prepared.item_ids)),\n dtype=torch.long,\n device=device,\n )\n item_parts.append(model.encode_items(rows).cpu())\n items = torch.cat(item_parts)\n rows: list[tuple[int, int, int]] = []\n for start in range(0, len(eligibility.uids), ranking_batch_size):\n end = min(start + ranking_batch_size, len(eligibility.uids))\n scores = queries[start:end] @ items.T\n eligible = np.unpackbits(\n eligibility.packed[start:end],\n axis=1,\n count=len(prepared.item_ids),\n bitorder=\"little\",\n ).astype(bool, copy=False)\n scores.masked_fill_(~torch.from_numpy(eligible), -torch.inf)\n selected = torch.topk(scores, k=base.TOP_N, dim=1).indices.numpy()\n for local_row, columns in enumerate(selected):\n uid = int(eligibility.uids[start + local_row])\n rows.extend(\n (uid, int(prepared.item_ids[column]), rank)\n for rank, column in enumerate(columns, start=1)\n )\n if end == len(eligibility.uids) or end % 1_024 == 0:\n log(f\"ranked users={end:,}/{len(eligibility.uids):,}\")\n query_norm = torch.linalg.vector_norm(queries, dim=1).numpy()\n item_norm = torch.linalg.vector_norm(items, dim=1).numpy()\n diagnostics = {\n \"query_norm_mean\": float(query_norm.mean()),\n \"query_norm_std\": float(query_norm.std()),\n \"item_norm_mean\": float(item_norm.mean()),\n \"item_norm_std\": float(item_norm.std()),\n }\n return pd.DataFrame(rows, columns=[\"uid\", \"item_id\", \"rank\"]), diagnostics\n\n\ndef write_ranking(frame: pd.DataFrame, output: Path) -> None:\n output.parent.mkdir(parents=True, exist_ok=True)\n if output.suffix == \".parquet\":\n frame.to_parquet(output, index=False, compression=\"zstd\")\n elif output.suffix == \".csv\":\n frame.to_csv(output, index=False)\n else:\n raise ValueError(\"--output must end in .parquet or .csv\")\n\n"
}
}