Safetensors
hiiamkik commited on
Commit
01105a5
·
verified ·
1 Parent(s): d8398d7

Upload env.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. env.py +310 -0
env.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RecoWorld MDP环境
3
+ State : (user_id, history_iids, mindset_vec, session_step, last_instruction)
4
+ Action : Top-K推荐列表 (ranked item indices)
5
+ Reward : watch_ratio + 留存 + 指令跟随 + 多样性
6
+ """
7
+ import pickle
8
+ import numpy as np
9
+ import pandas as pd
10
+ from dataclasses import dataclass, field
11
+ from typing import List, Dict, Optional, Tuple
12
+
13
+ from config import cfg
14
+
15
+
16
+ # ─────────────────────────────────────────────
17
+ # 数据结构
18
+ # ─────────────────────────────────────────────
19
+ @dataclass
20
+ class MDPState:
21
+ user_id: int
22
+ history_iids: List[int] # 历史交互item id列表
23
+ mindset: np.ndarray # 用户当前兴趣向量 (embed_dim,)
24
+ fatigue: float # 疲劳度 [0, 1]
25
+ session_step: int # 当前session步数
26
+ last_instruction: str # 上一轮用户反思指令 (空串=无)
27
+ done: bool = False
28
+
29
+ @dataclass
30
+ class StepResult:
31
+ next_state: MDPState
32
+ reward: float
33
+ done: bool
34
+ info: Dict # 详细奖励分量、用户行为等
35
+
36
+
37
+ # ─────────────────────────────────────────────
38
+ # KuaiRec 数据加载
39
+ # ─────────────────────────────────────────────
40
+ class KuaiRecEnvData:
41
+ def __init__(self):
42
+ self.interactions: pd.DataFrame = None
43
+ self.item_meta: pd.DataFrame = None
44
+ self.item_embeddings: np.ndarray = None # (n_items, embed_dim)
45
+ self.user2id: Dict = {}
46
+ self.item2id: Dict = {}
47
+ self.id2item: Dict = {}
48
+ self.id2text: Dict = {} # iid -> text description
49
+ self.n_users: int = 0
50
+ self.n_items: int = 0
51
+ self.user_histories: Dict[int, List[int]] = {} # uid -> sorted iid list
52
+ self.user_profiles: Dict[int, np.ndarray] = {} # uid -> mean embedding
53
+
54
+ def load(self) -> "KuaiRecEnvData":
55
+ print("Loading KuaiRec 2.0...")
56
+ inter = pd.read_csv(f"{cfg.data_dir}/big_matrix.csv")
57
+ item_meta = pd.read_csv(f"{cfg.data_dir}/item_categories.csv")
58
+
59
+ try:
60
+ daily = pd.read_csv(f"{cfg.data_dir}/item_daily_features.csv")
61
+ tags = daily[["video_id", "video_tag_name"]].drop_duplicates("video_id").rename(columns={"video_tag_name": "video_tag_list"})
62
+ item_meta = item_meta.merge(tags, on="video_id", how="left")
63
+ except FileNotFoundError:
64
+ item_meta["video_tag_list"] = ""
65
+
66
+ # 抽样item池
67
+ top_items = inter["video_id"].value_counts().head(cfg.item_pool_size).index
68
+ inter = inter[inter["video_id"].isin(top_items)]
69
+ item_meta = item_meta[item_meta["video_id"].isin(top_items)]
70
+
71
+ users = inter["user_id"].unique()
72
+ items = inter["video_id"].unique()
73
+ self.user2id = {u: i for i, u in enumerate(users)}
74
+ self.item2id = {v: i for i, v in enumerate(items)}
75
+ self.id2item = {i: v for v, i in self.item2id.items()}
76
+
77
+ inter["uid"] = inter["user_id"].map(self.user2id)
78
+ inter["iid"] = inter["video_id"].map(self.item2id)
79
+ inter["label"] = (inter["watch_ratio"] >= cfg.watch_ratio_threshold).astype(int)
80
+ inter = inter.sort_values("timestamp").reset_index(drop=True)
81
+
82
+ self.interactions = inter
83
+ self.item_meta = item_meta
84
+ self.n_users = len(users)
85
+ self.n_items = len(items)
86
+
87
+ # 构建item文本
88
+ for _, row in item_meta.iterrows():
89
+ vid = row["video_id"]
90
+ if vid in self.item2id:
91
+ iid = self.item2id[vid]
92
+ tags_str = str(row.get("video_tag_list", "")).replace(",", " ")
93
+ feat_str = str(row.get("feat", "")).replace(",", " ")
94
+ self.id2text[iid] = f"{tags_str} {feat_str}".strip() or f"video_{vid}"
95
+
96
+ # 构建用户历史序列
97
+ pos = inter[inter["label"] == 1]
98
+ for uid, grp in pos.groupby("uid"):
99
+ self.user_histories[uid] = grp.sort_values("timestamp")["iid"].tolist()
100
+
101
+ print(f" Users:{self.n_users:,} Items:{self.n_items:,} Interactions:{len(inter):,}")
102
+ return self
103
+
104
+ def get_item_text(self, iid: int) -> str:
105
+ return self.id2text.get(iid, f"video_{iid}")
106
+
107
+ def load_embeddings(self, path: str):
108
+ """加载预计算的item embeddings"""
109
+ data = np.load(path)
110
+ self.item_embeddings = data # (n_items, embed_dim)
111
+ # 计算用户profile = 历史item embedding均值
112
+ for uid, hist in self.user_histories.items():
113
+ if hist and self.item_embeddings is not None:
114
+ embs = [self.item_embeddings[iid] for iid in hist[-20:]
115
+ if iid < len(self.item_embeddings)]
116
+ if embs:
117
+ self.user_profiles[uid] = np.mean(embs, axis=0)
118
+ print(f" Loaded embeddings, user profiles: {len(self.user_profiles):,}")
119
+
120
+
121
+ # ─────────────────────────────────────────────
122
+ # MDP 环境
123
+ # ─────────────────────────────────────────────
124
+ class RecoWorldEnv:
125
+ def __init__(self, data: KuaiRecEnvData):
126
+ self.data = data
127
+ self._rng = np.random.default_rng(42)
128
+ # 预计算watch_ratio查找表: numpy矩阵 (n_users, n_items),比dict节省80%内存
129
+ self._wr_matrix: Optional[np.ndarray] = None
130
+ self._build_wr_table()
131
+
132
+ def _build_wr_table(self):
133
+ df = self.data.interactions[["uid", "iid", "watch_ratio"]].drop_duplicates(
134
+ subset=["uid", "iid"], keep="last"
135
+ )
136
+ n_users = self.data.n_users
137
+ n_items = self.data.n_items
138
+ mat = np.zeros((n_users, n_items), dtype=np.float16) # float16 再省一半
139
+ uids = df["uid"].values.astype(int)
140
+ iids = df["iid"].values.astype(int)
141
+ wrs = df["watch_ratio"].values.astype(np.float16)
142
+ mask = (uids < n_users) & (iids < n_items)
143
+ mat[uids[mask], iids[mask]] = wrs[mask]
144
+ self._wr_matrix = mat
145
+ # 释放DataFrame节省内存
146
+ import gc
147
+ del df
148
+ gc.collect()
149
+
150
+ def reset(self, uid: int) -> MDPState:
151
+ """初始化一个session"""
152
+ hist = self.data.user_histories.get(uid, [])
153
+ # 用训练集前80%作为初始历史
154
+ cutoff = int(len(hist) * 0.8)
155
+ init_hist = hist[:cutoff][-cfg.max_history_len:]
156
+
157
+ profile = self.data.user_profiles.get(uid)
158
+ mindset = profile.copy() if profile is not None else np.zeros(cfg.embed_dim)
159
+
160
+ return MDPState(
161
+ user_id=uid,
162
+ history_iids=init_hist,
163
+ mindset=mindset,
164
+ fatigue=0.0,
165
+ session_step=0,
166
+ last_instruction="",
167
+ done=False,
168
+ )
169
+
170
+ def step(self, state: MDPState, rec_list: List[int],
171
+ user_actions: List[str], instruction: str) -> StepResult:
172
+ """
173
+ 执行一步MDP
174
+ rec_list : 推荐的iid列表 (长度=rec_list_size)
175
+ user_actions : 每个item对应的行为 ["click","skip","leave",...]
176
+ instruction : 本轮用户发出的反思指令 (可为空)
177
+ """
178
+ uid = state.user_id
179
+ total_reward = 0.0
180
+ info = {"click": 0, "skip": 0, "leave": False,
181
+ "watch_ratios": [], "instruction_followed": False}
182
+
183
+ # ── 即时奖励 ──
184
+ new_history = state.history_iids.copy()
185
+ for iid, action in zip(rec_list, user_actions):
186
+ wr = float(self._wr_matrix[uid, iid]) if self._wr_matrix is not None else 0.0
187
+ info["watch_ratios"].append(wr)
188
+
189
+ if action == "click":
190
+ total_reward += cfg.reward_click + wr * 0.5
191
+ info["click"] += 1
192
+ new_history.append(iid)
193
+ elif action == "skip":
194
+ total_reward += cfg.reward_skip
195
+ info["skip"] += 1
196
+ elif action == "leave":
197
+ total_reward += cfg.reward_leave
198
+ info["leave"] = True
199
+ break
200
+
201
+ # ── 留存奖励 ──
202
+ total_reward += cfg.reward_session_step
203
+
204
+ # ── 多样性惩罚 ──
205
+ diversity_penalty = self._compute_diversity_penalty(rec_list)
206
+ total_reward += diversity_penalty
207
+ info["diversity_penalty"] = diversity_penalty
208
+
209
+ # ── 指令跟随奖励 ──
210
+ inst_reward = 0.0
211
+ if state.last_instruction and self.data.item_embeddings is not None:
212
+ inst_reward = self._compute_instruction_reward(
213
+ state.last_instruction, rec_list)
214
+ total_reward += inst_reward
215
+ info["instruction_followed"] = inst_reward > 0.1
216
+ info["instruction_reward"] = inst_reward
217
+
218
+ # ── 更新状态 ──
219
+ new_fatigue = min(1.0, state.fatigue * cfg.fatigue_decay + 0.1 * info["click"])
220
+ new_mindset = self._update_mindset(state.mindset, rec_list, user_actions)
221
+ done = info["leave"] or state.session_step + 1 >= cfg.max_session_steps
222
+
223
+ next_state = MDPState(
224
+ user_id=uid,
225
+ history_iids=new_history[-cfg.max_history_len:],
226
+ mindset=new_mindset,
227
+ fatigue=new_fatigue,
228
+ session_step=state.session_step + 1,
229
+ last_instruction=instruction,
230
+ done=done,
231
+ )
232
+ return StepResult(next_state=next_state, reward=total_reward,
233
+ done=done, info=info)
234
+
235
+ def _compute_diversity_penalty(self, rec_list: List[int]) -> float:
236
+ if self.data.item_embeddings is None or len(rec_list) < 2:
237
+ return 0.0
238
+ embs = np.array([self.data.item_embeddings[iid]
239
+ for iid in rec_list if iid < len(self.data.item_embeddings)])
240
+ if len(embs) < 2:
241
+ return 0.0
242
+ norms = np.linalg.norm(embs, axis=1, keepdims=True)
243
+ normed = embs / (norms + 1e-9)
244
+ sim_matrix = normed @ normed.T
245
+ upper = sim_matrix[np.triu_indices(len(normed), k=1)]
246
+ if np.mean(upper) > cfg.diversity_sim_threshold:
247
+ return cfg.reward_diversity_penalty
248
+ return 0.0
249
+
250
+ def _compute_instruction_reward(self, instruction: str,
251
+ rec_list: List[int]) -> float:
252
+ """
253
+ 语义型指令跟随奖励:
254
+ 用指令 embedding 和推荐 item embedding 的余弦相似度衡量跟随度。
255
+ 指令 embedding 用历史缓存的用户 mindset 近似(避免重复调 API)。
256
+ """
257
+ if not instruction or self.data.item_embeddings is None:
258
+ return 0.0
259
+
260
+ # 用指令关键词匹配 item text,抽取命中 item 的 embedding 均值作为指令向量
261
+ instr_words = set(instruction.lower().split())
262
+ hit_embs = []
263
+ for iid in range(min(len(self.data.item_embeddings), self.data.n_items)):
264
+ text_words = set(self.data.id2text.get(iid, "").lower().split())
265
+ if len(instr_words & text_words) >= 1:
266
+ hit_embs.append(self.data.item_embeddings[iid])
267
+ if len(hit_embs) >= 50:
268
+ break
269
+
270
+ if not hit_embs:
271
+ return 0.0
272
+
273
+ instr_emb = np.mean(hit_embs, axis=0)
274
+ instr_norm = instr_emb / (np.linalg.norm(instr_emb) + 1e-9)
275
+
276
+ # 计算推荐列表中每个 item 与指令的余弦相似度
277
+ sims = []
278
+ for iid in rec_list:
279
+ if iid < len(self.data.item_embeddings):
280
+ item_emb = self.data.item_embeddings[iid]
281
+ item_norm = item_emb / (np.linalg.norm(item_emb) + 1e-9)
282
+ sims.append(float(instr_norm @ item_norm))
283
+
284
+ if not sims:
285
+ return 0.0
286
+
287
+ avg_sim = np.mean(sims)
288
+ # sim 在 [-1,1],归一化到 [0,1] 再乘奖励系数
289
+ return cfg.reward_instruction_follow * max(0.0, avg_sim)
290
+
291
+ def _update_mindset(self, mindset: np.ndarray,
292
+ rec_list: List[int], actions: List[str]) -> np.ndarray:
293
+ """点击的item embedding加权平均更新mindset"""
294
+ if self.data.item_embeddings is None:
295
+ return mindset
296
+ clicked = [iid for iid, a in zip(rec_list, actions)
297
+ if a == "click" and iid < len(self.data.item_embeddings)]
298
+ if not clicked:
299
+ return mindset * 0.95 # 无点击,兴趣向量衰减
300
+ click_embs = np.mean([self.data.item_embeddings[iid] for iid in clicked], axis=0)
301
+ return mindset * 0.7 + click_embs * 0.3
302
+
303
+ def get_item_text(self, iid: int) -> str:
304
+ return self.data.id2text.get(iid, f"video_{iid}")
305
+
306
+ def sample_users(self, n: int) -> List[int]:
307
+ """采样有足够历史的用户"""
308
+ valid = [uid for uid, hist in self.data.user_histories.items()
309
+ if len(hist) >= 10]
310
+ return list(self._rng.choice(valid, size=min(n, len(valid)), replace=False))