Safetensors
hiiamkik commited on
Commit
0cde3e8
ยท
verified ยท
1 Parent(s): 1fe00a3

Upload rec_agent.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. rec_agent.py +429 -0
rec_agent.py ADDED
@@ -0,0 +1,429 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Rec Agent
3
+ ๆžถๆž„๏ผš
4
+ 1. FAISSๅ‘้‡ๅฌๅ›ž Top-50๏ผˆๅ›บๅฎš๏ผŒไธๅ‚ไธŽ่ฎญ็ปƒ๏ผ‰
5
+ 2. Qwen embedding็”Ÿๆˆcontextๅ‘้‡๏ผˆๅ›บๅฎš๏ผ‰
6
+ 3. MLP ranking head๏ผš่พ“ๅ…ฅ(user_ctx, item_emb, instruction_emb)๏ผŒ่พ“ๅ‡บscore
7
+ 4. MLPๆ˜ฏGRPOไผ˜ๅŒ–็š„็›ฎๆ ‡
8
+
9
+ ๆŒ‡ไปค่ทŸ้š๏ผšๅฐ†last_instruction็ผ–็ ไธบembedding๏ผŒไธŽitem embedding่ฎก็ฎ—็›ธไผผๅบฆ๏ผŒ
10
+ ไฝœไธบranking head็š„้ขๅค–็‰นๅพใ€‚
11
+ """
12
+ import os
13
+ import pickle
14
+ import numpy as np
15
+ import torch
16
+ import torch.nn as nn
17
+ import torch.nn.functional as F
18
+ import faiss
19
+ from typing import List, Optional
20
+ from openai import OpenAI
21
+
22
+ from config import cfg
23
+ from env import MDPState, KuaiRecEnvData
24
+
25
+ client = OpenAI(
26
+ api_key=cfg.dashscope_api_key,
27
+ base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
28
+ )
29
+
30
+
31
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
32
+ # Embeddingๅทฅๅ…ท
33
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
34
+ _embed_cache: dict = {}
35
+ _embed_cache_path = f"{cfg.cache_dir}/embed_cache.pkl"
36
+
37
+ def _load_embed_cache():
38
+ global _embed_cache
39
+ if os.path.exists(_embed_cache_path):
40
+ with open(_embed_cache_path, "rb") as f:
41
+ _embed_cache = pickle.load(f)
42
+
43
+ def _save_embed_cache():
44
+ with open(_embed_cache_path, "wb") as f:
45
+ pickle.dump(_embed_cache, f)
46
+
47
+ def encode_text(text: str) -> np.ndarray:
48
+ """ๅ•ๆกๆ–‡ๆœฌembedding๏ผŒๅธฆ็ผ“ๅญ˜"""
49
+ if text in _embed_cache:
50
+ return _embed_cache[text]
51
+ resp = client.embeddings.create(model=cfg.embed_model, input=[text[:512]])
52
+ emb = np.array(resp.data[0].embedding, dtype=np.float32)
53
+ _embed_cache[text] = emb
54
+ _save_embed_cache()
55
+ return emb
56
+
57
+ def encode_texts_batch(texts: List[str], batch_size: int = 25) -> np.ndarray:
58
+ results = []
59
+ for i in range(0, len(texts), batch_size):
60
+ batch = texts[i:i+batch_size]
61
+ uncached = [(j, t) for j, t in enumerate(batch) if t not in _embed_cache]
62
+ if uncached:
63
+ resp = client.embeddings.create(
64
+ model=cfg.embed_model,
65
+ input=[t[:512] for _, t in uncached]
66
+ )
67
+ for (j, t), d in zip(uncached, resp.data):
68
+ emb = np.array(d.embedding, dtype=np.float32)
69
+ _embed_cache[t] = emb
70
+ _save_embed_cache()
71
+ results.extend([_embed_cache[t] for t in batch])
72
+ return np.array(results, dtype=np.float32)
73
+
74
+ _load_embed_cache()
75
+
76
+
77
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
78
+ # MLP Ranking Head๏ผˆGRPOไผ˜ๅŒ–็›ฎๆ ‡๏ผ‰
79
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
80
+ class RankingHead(nn.Module):
81
+ """
82
+ ่พ“ๅ…ฅ: [user_emb(1536) | item_emb(1536) | instruction_sim(1) | fatigue(1) | step(1)]
83
+ ่พ“ๅ‡บ: score scalar
84
+ """
85
+ INPUT_DIM = cfg.embed_dim * 2 + 3 # 1536+1536+3 = 3075
86
+
87
+ def __init__(self):
88
+ super().__init__()
89
+ self.net = nn.Sequential(
90
+ nn.Linear(self.INPUT_DIM, 512),
91
+ nn.LayerNorm(512),
92
+ nn.ReLU(),
93
+ nn.Dropout(0.1),
94
+ nn.Linear(512, 128),
95
+ nn.ReLU(),
96
+ nn.Linear(128, 1),
97
+ )
98
+ self._init_weights()
99
+
100
+ def _init_weights(self):
101
+ for m in self.modules():
102
+ if isinstance(m, nn.Linear):
103
+ nn.init.xavier_uniform_(m.weight)
104
+ nn.init.zeros_(m.bias)
105
+
106
+ def forward(self, user_emb, item_emb, instruction_sim, fatigue, step):
107
+ """
108
+ user_emb : (B, 1536)
109
+ item_emb : (B, 1536)
110
+ instruction_sim: (B, 1)
111
+ fatigue : (B, 1)
112
+ step : (B, 1)
113
+ """
114
+ feat = torch.cat([user_emb, item_emb, instruction_sim, fatigue, step], dim=-1)
115
+ return self.net(feat).squeeze(-1) # (B,)
116
+
117
+ def score_candidates(
118
+ self,
119
+ user_emb: np.ndarray, # (embed_dim,)
120
+ item_embs: np.ndarray, # (K, embed_dim)
121
+ instruction_emb: Optional[np.ndarray], # (embed_dim,) or None
122
+ fatigue: float,
123
+ step: int,
124
+ device: str,
125
+ ) -> np.ndarray:
126
+ """ๆ‰น้‡ๅฏนๅ€™้€‰ๆ‰“ๅˆ†๏ผŒ่ฟ”ๅ›žscores (K,)"""
127
+ K = len(item_embs)
128
+ u = torch.tensor(np.tile(user_emb, (K, 1)), dtype=torch.float32).to(device)
129
+ it = torch.tensor(item_embs, dtype=torch.float32).to(device)
130
+
131
+ if instruction_emb is not None:
132
+ instr = torch.tensor(instruction_emb, dtype=torch.float32).to(device)
133
+ instr_norm = F.normalize(instr.unsqueeze(0), dim=-1)
134
+ item_norm = F.normalize(it, dim=-1)
135
+ sim = (item_norm @ instr_norm.T).squeeze(-1).unsqueeze(-1) # (K,1)
136
+ else:
137
+ sim = torch.zeros(K, 1).to(device)
138
+
139
+ fat = torch.full((K, 1), fatigue, dtype=torch.float32).to(device)
140
+ stp = torch.full((K, 1), step / cfg.max_session_steps, dtype=torch.float32).to(device)
141
+
142
+ self.eval()
143
+ with torch.no_grad():
144
+ scores = self(u, it, sim, fat, stp).cpu().numpy()
145
+ return scores
146
+
147
+
148
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
149
+ # Transformer Ranking Head๏ผˆListwise๏ผŒๅฏน้ฝ OneRec๏ผ‰
150
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
151
+ class TransformerRankingHead(nn.Module):
152
+ """
153
+ Listwise Transformer ๆŽ’ๅบๅคด
154
+
155
+ ไธŽ MLP๏ผˆpointwise๏ผ‰็š„ๅŒบๅˆซ๏ผš
156
+ MLP๏ผšๆฏไธชๅ€™้€‰ item ็‹ฌ็ซ‹ๆ‰“ๅˆ†๏ผŒไบ’็›ธ็œ‹ไธ่ง
157
+ Transformer๏ผšๆ‰€ๆœ‰ๅ€™้€‰ item ไธ€่ตท่ฟ‡ self-attention๏ผŒ
158
+ ่ƒฝๆ•ๆ‰ item-item ไบคไบ’๏ผˆๅคšๆ ทๆ€งใ€ไบ’่กฅๆ€ง๏ผ‰
159
+ ๆ›ดๆŽฅ่ฟ‘ OneRec session-wise ็š„ๆ•ดไฝ“ๅปบๆจกๆ€่ทฏ
160
+
161
+ ่พ“ๅ…ฅๆ ผๅผไธŽ RankingHead ๅฎŒๅ…จๅ…ผๅฎน๏ผˆdrop-in ๆ›ฟๆข๏ผ‰๏ผš
162
+ user_emb: (K, 1536) - ๅŒไธ€็”จๆˆทๅนณ้“บ K ๆฌก
163
+ item_emb: (K, 1536)
164
+ instruction_sim: (K, 1)
165
+ fatigue: (K, 1)
166
+ step: (K, 1)
167
+ ่พ“ๅ‡บ: scores (K,)
168
+
169
+ ๅ‚ๆ•ฐ้‡๏ผš็บฆ 5M๏ผˆvs MLP ็š„ 1.6M๏ผ‰
170
+ """
171
+ D_MODEL = 256
172
+ N_HEAD = 8
173
+ N_LAYERS = 3
174
+ DIM_FF = 512
175
+
176
+ def __init__(self):
177
+ super().__init__()
178
+ d = self.D_MODEL
179
+
180
+ # ่พ“ๅ…ฅๆŠ•ๅฝฑ
181
+ self.user_proj = nn.Sequential(
182
+ nn.Linear(cfg.embed_dim, d),
183
+ nn.LayerNorm(d),
184
+ )
185
+ self.item_proj = nn.Sequential(
186
+ nn.Linear(cfg.embed_dim, d),
187
+ nn.LayerNorm(d),
188
+ )
189
+ # context๏ผˆinstruction_sim + fatigue + step๏ผ‰่žๅ…ฅ item token
190
+ self.ctx_proj = nn.Linear(3, d)
191
+
192
+ # Transformer encoder โ€” Pre-LN๏ผŒ่ฎญ็ปƒๆ›ด็จณๅฎš
193
+ encoder_layer = nn.TransformerEncoderLayer(
194
+ d_model=d,
195
+ nhead=self.N_HEAD,
196
+ dim_feedforward=self.DIM_FF,
197
+ dropout=0.1,
198
+ batch_first=True,
199
+ norm_first=True,
200
+ )
201
+ self.transformer = nn.TransformerEncoder(encoder_layer, num_layers=self.N_LAYERS)
202
+
203
+ # ๆ‰“ๅˆ†ๅคด
204
+ self.score_head = nn.Linear(d, 1)
205
+ self._init_weights()
206
+
207
+ def _init_weights(self):
208
+ for m in self.modules():
209
+ if isinstance(m, nn.Linear):
210
+ nn.init.xavier_uniform_(m.weight)
211
+ if m.bias is not None:
212
+ nn.init.zeros_(m.bias)
213
+
214
+ def forward(self, user_emb, item_emb, instruction_sim, fatigue, step):
215
+ """
216
+ 1. user embedding โ†’ CLS token๏ผˆ็ป™ๆ•ดไธชๅ€™้€‰ๅบๅˆ—ๆไพ›็”จๆˆทไธŠไธ‹ๆ–‡๏ผ‰
217
+ 2. item embeddings + context โ†’ K ไธช item token
218
+ 3. [CLS, item_1, ..., item_K] โ†’ Transformer โ†’ ๆฏไธช item ่พ“ๅ‡บๆ‰“ๅˆ†
219
+ """
220
+ # user token๏ผšๅŒไธ€็”จๆˆทๅ–็ฌฌไธ€่กŒ๏ผŒๆŠ•ๅฝฑไธบ (1, D)
221
+ user_token = self.user_proj(user_emb[0:1]) # (1, D)
222
+
223
+ # item tokens๏ผšๆŠ•ๅฝฑ + context ่žๅˆ
224
+ item_tokens = self.item_proj(item_emb) # (K, D)
225
+ ctx = torch.cat([instruction_sim, fatigue, step], dim=-1) # (K, 3)
226
+ item_tokens = item_tokens + self.ctx_proj(ctx) # (K, D)
227
+
228
+ # ๆ‹ผๅบๅˆ—๏ผš[CLS, item_1, ..., item_K] โ†’ (1, K+1, D)
229
+ seq = torch.cat([user_token, item_tokens], dim=0).unsqueeze(0)
230
+
231
+ # Transformer ่”ๅˆ็ผ–็ ๏ผˆitem-item self-attention + user context๏ผ‰
232
+ out = self.transformer(seq) # (1, K+1, D)
233
+
234
+ # ๅ– item ้ƒจๅˆ†๏ผˆ่ทณ่ฟ‡ CLS๏ผ‰ๆ‰“ๅˆ†
235
+ item_out = out[0, 1:, :] # (K, D)
236
+ scores = self.score_head(item_out).squeeze(-1) # (K,)
237
+ return scores
238
+
239
+ def score_candidates(
240
+ self,
241
+ user_emb: np.ndarray,
242
+ item_embs: np.ndarray,
243
+ instruction_emb: Optional[np.ndarray],
244
+ fatigue: float,
245
+ step: int,
246
+ device: str,
247
+ ) -> np.ndarray:
248
+ """ไธŽ RankingHead ๆŽฅๅฃๅฎŒๅ…จไธ€่‡ด"""
249
+ K = len(item_embs)
250
+ u = torch.tensor(np.tile(user_emb, (K, 1)), dtype=torch.float32).to(device)
251
+ it = torch.tensor(item_embs, dtype=torch.float32).to(device)
252
+
253
+ if instruction_emb is not None:
254
+ instr = torch.tensor(instruction_emb, dtype=torch.float32).to(device)
255
+ instr_norm = F.normalize(instr.unsqueeze(0), dim=-1)
256
+ item_norm = F.normalize(it, dim=-1)
257
+ sim = (item_norm @ instr_norm.T).squeeze(-1).unsqueeze(-1)
258
+ else:
259
+ sim = torch.zeros(K, 1).to(device)
260
+
261
+ fat = torch.full((K, 1), fatigue, dtype=torch.float32).to(device)
262
+ stp = torch.full((K, 1), step / cfg.max_session_steps, dtype=torch.float32).to(device)
263
+
264
+ self.eval()
265
+ with torch.no_grad():
266
+ scores = self(u, it, sim, fat, stp).cpu().numpy()
267
+ return scores
268
+
269
+
270
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
271
+ # FAISS ๅฌๅ›ž็ดขๅผ•
272
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
273
+ class FAISSRetriever:
274
+ def __init__(self, item_embeddings: np.ndarray, item_ids: List[int]):
275
+ self.item_ids = item_ids
276
+ norms = np.linalg.norm(item_embeddings, axis=1, keepdims=True)
277
+ normed = item_embeddings / (norms + 1e-9)
278
+ dim = normed.shape[1]
279
+ index = faiss.IndexFlatIP(dim)
280
+ if faiss.get_num_gpus() > 0:
281
+ res = faiss.StandardGpuResources()
282
+ index = faiss.index_cpu_to_gpu(res, 0, index)
283
+ index.add(normed.astype(np.float32))
284
+ self.index = index
285
+ self.embeddings = normed
286
+
287
+ def retrieve(self, query_emb: np.ndarray, topk: int) -> List[int]:
288
+ q = query_emb / (np.linalg.norm(query_emb) + 1e-9)
289
+ q = q.astype(np.float32).reshape(1, -1)
290
+ _, idx = self.index.search(q, topk)
291
+ return [self.item_ids[i] for i in idx[0] if i < len(self.item_ids)]
292
+
293
+
294
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
295
+ # Rec Agent
296
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
297
+ class RecAgent:
298
+ def __init__(self, data: KuaiRecEnvData, ranking_head: RankingHead,
299
+ intent_classifier=None, iid2cat: dict = None,
300
+ ar_reranker=None):
301
+ self.data = data
302
+ self.ranking_head = ranking_head.to(cfg.device)
303
+ self.retriever: Optional[FAISSRetriever] = None
304
+ self._instruction_emb_cache: dict = {}
305
+ self.intent_classifier = intent_classifier
306
+ self.iid2cat = iid2cat or {}
307
+ self.ar_reranker = ar_reranker # AutoregressiveReranker๏ผŒๅฏ้€‰
308
+
309
+ def build_retriever(self):
310
+ """ๆž„ๅปบFAISS็ดขๅผ•๏ผˆ้œ€่ฆitem embeddingsๅทฒๅŠ ่ฝฝ๏ผ‰"""
311
+ assert self.data.item_embeddings is not None
312
+ all_iids = list(range(self.data.n_items))
313
+ self.retriever = FAISSRetriever(self.data.item_embeddings, all_iids)
314
+ print(f"FAISS index built: {self.data.n_items} items")
315
+
316
+ def recommend(self, state: MDPState, env=None) -> List[int]:
317
+ """
318
+ ๅฎŒๆ•ดๆŽจ่ๆต็จ‹๏ผš
319
+ 1. ็”จmindsetๅšFAISSๅฌๅ›ž Top-50
320
+ 2. MLP ranking head้‡ๆŽ’๏ผŒๅ–Top-10
321
+ """
322
+ # โ”€โ”€ 1. ๅฌๅ›ž โ”€โ”€
323
+ if self.retriever is None or self.data.item_embeddings is None:
324
+ candidates = list(np.random.choice(self.data.n_items,
325
+ cfg.recall_topk, replace=False))
326
+ else:
327
+ # ๅ‘้‡ๅฌๅ›ž
328
+ candidates = self.retriever.retrieve(state.mindset, cfg.recall_topk)
329
+
330
+ # ๆ„ๅ›พๅฌๅ›ž๏ผš็”จ IntentClassifier ้ข„ๆต‹ๅฝ“ๅ‰ๆ„ๅ›พ๏ผŒ่กฅๅ……ๅŒ็ฑป item
331
+ if self.intent_classifier is not None and self.iid2cat and state.history_iids:
332
+ hist_embs = np.array([
333
+ self.data.item_embeddings[iid]
334
+ for iid in state.history_iids[-20:]
335
+ if iid < len(self.data.item_embeddings)
336
+ ], dtype=np.float32)
337
+ if len(hist_embs) > 0:
338
+ intent_cat, _ = self.intent_classifier.predict(hist_embs)
339
+ # ๆ‰พๅŒ็ฑป item ่กฅๅ……ๅˆฐๅ€™้€‰ๆฑ 
340
+ same_cat = [iid for iid, cat in self.iid2cat.items()
341
+ if cat == intent_cat and iid not in set(candidates)]
342
+ if same_cat:
343
+ extra = list(np.random.choice(same_cat,
344
+ size=min(10, len(same_cat)),
345
+ replace=False))
346
+ candidates = candidates + extra
347
+
348
+ # ่ฟ‡ๆปคๅทฒ็œ‹่ฟ‡็š„item
349
+ seen = set(state.history_iids[-50:])
350
+ candidates = [iid for iid in candidates if iid not in seen]
351
+ if not candidates:
352
+ candidates = list(np.random.choice(self.data.n_items,
353
+ cfg.rec_list_size, replace=False))
354
+
355
+ # โ”€โ”€ 2. ่Žทๅ–instruction embedding โ”€โ”€
356
+ instr_emb = None
357
+ if state.last_instruction:
358
+ if state.last_instruction not in self._instruction_emb_cache:
359
+ self._instruction_emb_cache[state.last_instruction] = \
360
+ encode_text(state.last_instruction)
361
+ instr_emb = self._instruction_emb_cache[state.last_instruction]
362
+
363
+ # โ”€โ”€ 3. MLP้‡ๆŽ’ โ”€โ”€
364
+ cand_embs = np.array([
365
+ self.data.item_embeddings[iid]
366
+ if iid < len(self.data.item_embeddings)
367
+ else np.zeros(cfg.embed_dim, dtype=np.float32)
368
+ for iid in candidates
369
+ ], dtype=np.float32)
370
+
371
+ scores = self.ranking_head.score_candidates(
372
+ user_emb=state.mindset,
373
+ item_embs=cand_embs,
374
+ instruction_emb=instr_emb,
375
+ fatigue=state.fatigue,
376
+ step=state.session_step,
377
+ device=cfg.device,
378
+ )
379
+
380
+ # โ”€โ”€ ็ฒพๆŽ’๏ผšTransformerRankingHead โ†’ Top-20 โ”€โ”€
381
+ pre_k = min(20, len(candidates))
382
+ pre_idx = np.argsort(scores)[::-1][:pre_k]
383
+ pre_candidates = [candidates[i] for i in pre_idx]
384
+ pre_embs = cand_embs[pre_idx]
385
+
386
+ # โ”€โ”€ ้‡ๆŽ’๏ผšAutoregressiveReranker ็”Ÿๆˆๆœ€็ปˆ Top-10 โ”€โ”€
387
+ if self.ar_reranker is not None:
388
+ ar_idx, _ = self.ar_reranker.decode_greedy(
389
+ state.mindset, pre_embs, n_select=cfg.rec_list_size
390
+ )
391
+ return [pre_candidates[i] for i in ar_idx]
392
+
393
+ # ๆ— ้‡ๆŽ’ๆ—ถ็›ดๆŽฅๅ– Top-10
394
+ return pre_candidates[:cfg.rec_list_size]
395
+
396
+ def get_scoring_features(
397
+ self, state: MDPState, candidates: List[int]
398
+ ) -> torch.Tensor:
399
+ """
400
+ ไธบGRPOๆไพ›็‰นๅพๅผ ้‡๏ผŒshape: (K, INPUT_DIM)
401
+ ็”จไบŽ่ฎก็ฎ—log_prob
402
+ """
403
+ K = len(candidates)
404
+ instr_emb = None
405
+ if state.last_instruction and state.last_instruction in self._instruction_emb_cache:
406
+ instr_emb = self._instruction_emb_cache[state.last_instruction]
407
+
408
+ cand_embs = np.array([
409
+ self.data.item_embeddings[iid]
410
+ if (self.data.item_embeddings is not None and iid < len(self.data.item_embeddings))
411
+ else np.zeros(cfg.embed_dim, dtype=np.float32)
412
+ for iid in candidates
413
+ ], dtype=np.float32)
414
+
415
+ u = torch.tensor(np.tile(state.mindset, (K, 1)), dtype=torch.float32)
416
+ it = torch.tensor(cand_embs, dtype=torch.float32)
417
+
418
+ if instr_emb is not None:
419
+ instr_t = torch.tensor(instr_emb, dtype=torch.float32)
420
+ instr_norm = F.normalize(instr_t.unsqueeze(0), dim=-1)
421
+ item_norm = F.normalize(it, dim=-1)
422
+ sim = (item_norm @ instr_norm.T) # (K,1)
423
+ else:
424
+ sim = torch.zeros(K, 1)
425
+
426
+ fat = torch.full((K, 1), state.fatigue)
427
+ stp = torch.full((K, 1), state.session_step / cfg.max_session_steps)
428
+
429
+ return torch.cat([u, it, sim, fat, stp], dim=-1) # (K, INPUT_DIM)