Update app.py
Browse files
app.py
CHANGED
|
@@ -1,41 +1,46 @@
|
|
| 1 |
import os
|
| 2 |
-
import re
|
| 3 |
import json
|
|
|
|
| 4 |
import pickle
|
| 5 |
import threading
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import traceback
|
| 7 |
-
import requests
|
| 8 |
import numpy as np
|
| 9 |
-
from typing import *
|
| 10 |
from datetime import datetime
|
| 11 |
-
|
| 12 |
-
# Web 服务与 HF Hub 依赖
|
| 13 |
-
from fastapi import FastAPI
|
| 14 |
-
import uvicorn
|
| 15 |
from huggingface_hub import HfApi, hf_hub_download
|
| 16 |
|
| 17 |
# ==========================================
|
| 18 |
-
# [
|
| 19 |
# ==========================================
|
| 20 |
-
from
|
| 21 |
|
| 22 |
-
#
|
| 23 |
-
from
|
| 24 |
-
from
|
| 25 |
|
| 26 |
-
#
|
| 27 |
try:
|
| 28 |
from libriichiSanma import state as sanma_state
|
| 29 |
except ImportError:
|
| 30 |
import libriichi as sanma_state
|
| 31 |
|
| 32 |
# ==========================================
|
| 33 |
-
# [配置
|
| 34 |
# ==========================================
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
|
|
|
| 39 |
MASK_3P = [
|
| 40 |
"1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m",
|
| 41 |
"1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p",
|
|
@@ -44,371 +49,215 @@ MASK_3P = [
|
|
| 44 |
'5mr', '5pr', '5sr',
|
| 45 |
'reach', 'pon', 'kan', 'nukidora', 'hora', 'ryukyoku', 'none'
|
| 46 |
]
|
| 47 |
-
|
| 48 |
NONE_CODE = MASK_3P.index('none')
|
| 49 |
KAN_CODE = MASK_3P.index('kan')
|
| 50 |
-
_thread_local = threading.local()
|
| 51 |
|
|
|
|
| 52 |
worker_status = {
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"
|
| 56 |
-
"
|
| 57 |
-
"current_target": "",
|
| 58 |
-
"errors": 0
|
| 59 |
}
|
| 60 |
|
|
|
|
|
|
|
|
|
|
| 61 |
# ==========================================
|
| 62 |
-
# [
|
| 63 |
# ==========================================
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
if suit == 4: return 'ESWNPFC'[num - 1]
|
| 71 |
-
return '?'
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
result['pai'] = result['pai'][0]
|
| 89 |
-
if result.get('type') == 'ankan': result['consumed'].append(result['pai'])
|
| 90 |
-
return result
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
for i, event in enumerate(income):
|
| 96 |
-
if type(event) is str: incoming.append(cls.get_meld_tiles(actor, event))
|
| 97 |
-
else: incoming.append({'type': 'tsumo', 'pai': cls.tile_name(event), 'actor': actor})
|
| 98 |
-
for i, event in enumerate(outcome):
|
| 99 |
-
if type(event) is str and event[0] != 'r':
|
| 100 |
-
outcoming.append(cls.get_meld_tiles(actor, event))
|
| 101 |
-
else:
|
| 102 |
-
if event == 0:
|
| 103 |
-
outcoming.append({'type': 'empty'})
|
| 104 |
-
continue
|
| 105 |
-
reach = False
|
| 106 |
-
if type(event) is str and event[0] == 'r':
|
| 107 |
-
reach, event = True, int(event[1:])
|
| 108 |
-
outcoming.append({'type': 'reach', 'actor': actor})
|
| 109 |
-
outcoming.append({'type': 'dahai', 'pai': cls.tile_name(event if event != 60 else income[i]), 'actor': actor, 'tsumogiri': event == 60})
|
| 110 |
-
if reach: outcoming.append({'type': 'reach_accepted', 'actor': actor})
|
| 111 |
-
return incoming, outcoming
|
| 112 |
-
|
| 113 |
-
@classmethod
|
| 114 |
-
def merge_events(cls, oya, events, dora_markers):
|
| 115 |
-
current, result = oya, []
|
| 116 |
-
def finished(x): return all(len(i[0]) == 0 and len(i[1]) == 0 for i in x)
|
| 117 |
-
while not finished(events):
|
| 118 |
-
income, outcome = events[current]
|
| 119 |
-
nuki = False
|
| 120 |
-
if len(income):
|
| 121 |
-
result.append(income.pop(0))
|
| 122 |
-
if result[-1]['type'] == 'daiminkan':
|
| 123 |
-
if len(dora_markers) > 0: result.append({'type': 'dora', 'dora_marker': cls.tile_name(dora_markers.pop(0))})
|
| 124 |
-
outcome.pop(0)
|
| 125 |
-
continue
|
| 126 |
-
if len(outcome):
|
| 127 |
-
result.append(outcome.pop(0))
|
| 128 |
-
pai, t = result[-1].get('pai'), result[-1]['type']
|
| 129 |
-
if t == 'reach':
|
| 130 |
-
result.append(outcome.pop(0))
|
| 131 |
-
pai = result[-1].get('pai')
|
| 132 |
-
result.append(outcome.pop(0))
|
| 133 |
-
nuki = False
|
| 134 |
-
for actor, x in enumerate(events):
|
| 135 |
-
if actor == current or len(x[1]) == 0: continue
|
| 136 |
-
if x[0][0]['type'] != 'tsumo' and x[0][0].get('pai') == pai and not (x[0][0]['type'] == 'chi' and not (x[0][0]['actor'] + 3) % 4 == actor):
|
| 137 |
-
nuki, current = True, actor
|
| 138 |
-
break
|
| 139 |
-
if t in ('ankan', 'kakan', 'nukidora'):
|
| 140 |
-
if t != 'nukidora' and len(dora_markers) > 0: result.append({'type': 'dora', 'dora_marker': cls.tile_name(dora_markers.pop(0))})
|
| 141 |
-
nuki = True
|
| 142 |
-
if not nuki: current = (current + 1) % 4
|
| 143 |
-
return result
|
| 144 |
-
|
| 145 |
-
@classmethod
|
| 146 |
-
def parse_single_round(cls, data):
|
| 147 |
-
round_info, scores, dora_markers, uradora, result_info = data[0], data[1], data[2], data[3], data[-1]
|
| 148 |
-
oya = round_info[0] % 4
|
| 149 |
-
patch = lambda arr: arr if len(arr) >= 13 else [0] * 13
|
| 150 |
-
events = [{
|
| 151 |
-
'type': 'start_kyoku', 'bakaze': 'ESWN'[round_info[0] // 4], 'kyoku': oya + 1,
|
| 152 |
-
'honba': round_info[1], 'kyotaku': round_info[2], 'oya': oya,
|
| 153 |
-
'dora_marker': cls.tile_name(dora_markers.pop(0)), 'scores': scores,
|
| 154 |
-
# 这里的 data[13] 在三麻时虽然是结果数据,但 patch() 会把它变成 13 个 '?'
|
| 155 |
-
'tehais': [[cls.tile_name(i) for i in patch(data[k])] for k in [4, 7, 10, 13]]
|
| 156 |
-
}]
|
| 157 |
-
|
| 158 |
-
# 还原原版的做法:分别解析,只在四麻时解析第四个玩家
|
| 159 |
-
e1 = cls.parse_events(0, data[5], data[6])
|
| 160 |
-
e2 = cls.parse_events(1, data[8], data[9])
|
| 161 |
-
e3 = cls.parse_events(2, data[11], data[12])
|
| 162 |
-
e4 = ([], [])
|
| 163 |
-
if len(data) >= 15:
|
| 164 |
-
e4 = cls.parse_events(3, data[14], data[15])
|
| 165 |
-
|
| 166 |
-
e_list = [e1, e2, e3, e4]
|
| 167 |
-
events += cls.merge_events(oya, e_list, dora_markers)
|
| 168 |
-
|
| 169 |
-
last_type = events[-1]['type']
|
| 170 |
-
if last_type == 'tsumo' and result_info[0] == '和了':
|
| 171 |
-
events.append({'type': 'hora', 'actor': events[-1]['actor'], 'target': events[-1]['actor']})
|
| 172 |
-
elif result_info[0] == '和了':
|
| 173 |
-
actor = next(i for i, x in enumerate(result_info[1]) if x > 0)
|
| 174 |
-
events.append({'type': 'hora', 'actor': actor, 'target': actor})
|
| 175 |
-
elif last_type == 'tsumo' or '九牌' in result_info[0]:
|
| 176 |
-
events.append({'type': 'ryukyoku', 'actor': events[-1]['actor']})
|
| 177 |
-
|
| 178 |
-
return events
|
| 179 |
-
|
| 180 |
-
@classmethod
|
| 181 |
-
def parse_log(cls, log):
|
| 182 |
-
scores = log.get('sc', [])
|
| 183 |
-
weights = [1.0, 1.0, 1.0]
|
| 184 |
-
seat = log['name'].index('私') if '私' in log['name'] else -1
|
| 185 |
-
parsed_rounds = []
|
| 186 |
-
for i in log['log'][:]:
|
| 187 |
-
round_events = [{"type": "start_game", "id": seat, "weight": weights}] + cls.parse_single_round(i)
|
| 188 |
-
parsed_rounds.append(round_events)
|
| 189 |
-
return parsed_rounds
|
| 190 |
-
|
| 191 |
-
# ==========================================
|
| 192 |
-
# [特征拦截假引擎 (Teacher)]
|
| 193 |
-
# ==========================================
|
| 194 |
-
class DummyFeatureEngine:
|
| 195 |
-
def __init__(self):
|
| 196 |
-
self.engine_type = 'mortal'
|
| 197 |
-
self.name = 'DataMiner'
|
| 198 |
-
self.version = 4
|
| 199 |
-
self.is_oracle = False
|
| 200 |
-
self.enable_quick_eval = True
|
| 201 |
-
self.enable_rule_based_agari_guard = True
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
try: valid_action = m_list.index(True)
|
| 212 |
-
except ValueError: valid_action = 0
|
| 213 |
-
actions.append(valid_action)
|
| 214 |
-
q_outs.append([0.0] * len(m_list))
|
| 215 |
-
return actions, q_outs, pure_masks, [True] * batch_size
|
| 216 |
|
| 217 |
# ==========================================
|
| 218 |
-
# [
|
| 219 |
# ==========================================
|
| 220 |
-
class
|
| 221 |
-
def __init__(self, chunk_size=2048
|
| 222 |
self.chunk_size = chunk_size
|
| 223 |
-
self.
|
| 224 |
-
self.
|
| 225 |
-
self.chunk_count = 0
|
| 226 |
-
self.hf_api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 227 |
-
|
| 228 |
-
self.local_pool_dir = "local_chunks_pool"
|
| 229 |
os.makedirs(self.local_pool_dir, exist_ok=True)
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
if action['type'] in MASK_3P: return MASK_3P.index(action['type'])
|
| 239 |
-
raise Exception(f"Unknown action map: {action}")
|
| 240 |
-
|
| 241 |
-
def save_and_check_upload(self):
|
| 242 |
-
if not self.inputs: return
|
| 243 |
|
| 244 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
filepath = os.path.join(self.local_pool_dir, filename)
|
| 246 |
|
|
|
|
| 247 |
with open(filepath, 'wb') as f:
|
| 248 |
-
pickle.dump({'inputs': self.inputs, 'outputs': self.outputs
|
| 249 |
-
|
| 250 |
-
print(f"📦 已生成蒸馏缓存: {filename} ({len(self.inputs)} records).")
|
| 251 |
|
| 252 |
-
self.chunk_count += 1
|
| 253 |
self.inputs.clear()
|
| 254 |
self.outputs.clear()
|
| 255 |
-
self.weights.clear()
|
| 256 |
-
|
| 257 |
-
current_files = os.listdir(self.local_pool_dir)
|
| 258 |
-
if len(current_files) >= self.pool_size:
|
| 259 |
-
self.upload_pool()
|
| 260 |
-
|
| 261 |
-
def upload_pool(self):
|
| 262 |
-
current_files = os.listdir(self.local_pool_dir)
|
| 263 |
-
if not current_files or not self.hf_api or not DATASET_REPO: return
|
| 264 |
-
|
| 265 |
-
import time
|
| 266 |
-
print(f"🚀 本地池满,正在批量上传 {len(current_files)} 个文件...")
|
| 267 |
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
folder_path=self.local_pool_dir,
|
| 272 |
-
path_in_repo="distill_better_3",
|
| 273 |
-
repo_id=DATASET_REPO,
|
| 274 |
-
repo_type="dataset"
|
| 275 |
-
)
|
| 276 |
-
print(f"✅ 上传成功 (Attempt {attempt + 1}).")
|
| 277 |
-
worker_status["total_chunks_uploaded"] += len(current_files)
|
| 278 |
-
for f in current_files: os.remove(os.path.join(self.local_pool_dir, f))
|
| 279 |
-
break
|
| 280 |
-
except Exception as e:
|
| 281 |
-
wait_time = 5 * (2 ** attempt)
|
| 282 |
-
print(f"⚠️ Upload failed: {e}. Waiting {wait_time}s...")
|
| 283 |
-
time.sleep(wait_time)
|
| 284 |
|
| 285 |
-
def
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 291 |
|
| 292 |
-
for
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
|
| 298 |
-
#
|
| 299 |
-
|
| 300 |
-
bot_teacher = RiichiBot(DummyFeatureEngine(), who)
|
| 301 |
-
|
| 302 |
-
if ps_student is None or bot_teacher is None:
|
| 303 |
-
continue
|
| 304 |
-
|
| 305 |
-
if event.get('type') == 'end_game':
|
| 306 |
-
continue
|
| 307 |
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
if events[j].get('type') not in ('dora', 'reach_accepted'):
|
| 311 |
-
next_event = events[j]; break
|
| 312 |
-
|
| 313 |
-
event_str = json.dumps(event, separators=(",", ":"))
|
| 314 |
-
|
| 315 |
-
# --- 1. Teacher 更新与拦截 ---
|
| 316 |
-
_thread_local.interception = None
|
| 317 |
-
bot_teacher.react(event_str)
|
| 318 |
-
intercepted = getattr(_thread_local, 'interception', None)
|
| 319 |
-
|
| 320 |
-
# --- 2. Student 更新与特征生成 ---
|
| 321 |
-
cans = ps_student.update(event_str)
|
| 322 |
-
|
| 323 |
-
if intercepted is None or not cans.can_act:
|
| 324 |
-
continue
|
| 325 |
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
|
| 336 |
-
#
|
| 337 |
-
|
| 338 |
-
"obs_student": obs_s,
|
| 339 |
-
"mask_student": mask_s,
|
| 340 |
-
"obs_teacher": obs_t[0], # 去除 batch 维度
|
| 341 |
-
"mask_teacher": masks_t[0]
|
| 342 |
-
})
|
| 343 |
-
self.outputs.append(output_code)
|
| 344 |
-
self.weights.append(current_weight)
|
| 345 |
|
| 346 |
-
|
| 347 |
-
|
|
|
|
| 348 |
|
| 349 |
-
if
|
| 350 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
# ==========================================
|
| 353 |
-
# [
|
| 354 |
# ==========================================
|
| 355 |
-
def
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
url_file_path = hf_hub_download(repo_id=DATASET_REPO, filename=URL_LIST_FILE, repo_type="dataset", token=HF_TOKEN)
|
| 363 |
-
with open(url_file_path, 'r') as f: target_urls = [line.strip() for line in f if line.strip()]
|
| 364 |
-
except Exception as e:
|
| 365 |
-
worker_status["status"] = f"Failed to fetch {URL_LIST_FILE}: {e}"
|
| 366 |
-
return
|
| 367 |
-
|
| 368 |
-
encoder = FeatureEncoder(chunk_size=2048, pool_size=8)
|
| 369 |
-
worker_status["status"] = "Mining..."
|
| 370 |
-
|
| 371 |
-
for url in target_urls:
|
| 372 |
-
worker_status["current_target"] = url
|
| 373 |
-
|
| 374 |
try:
|
| 375 |
-
|
| 376 |
-
paipu_json_str = download_paipu(url)
|
| 377 |
-
paipu_data = json.loads(paipu_json_str)
|
| 378 |
-
|
| 379 |
-
# 2. 从解析后的数据中提取目标玩家座位 (0, 1, 2)
|
| 380 |
-
target_seat = paipu_data.get('pov', -1)
|
| 381 |
-
|
| 382 |
-
# 3. 将统一的 JSON 数据喂给解析器
|
| 383 |
-
parsed_games = TenhouParser.parse_log(paipu_data)
|
| 384 |
-
|
| 385 |
-
for game in parsed_games:
|
| 386 |
-
# 默认是三麻,遍历3个座位
|
| 387 |
-
for j in range(3):
|
| 388 |
-
# 如果能解析出固定视角(target_seat != -1),就只处理那个视角的特征,丢弃其他视角的冗余计算
|
| 389 |
-
if target_seat != -1 and j != target_seat:
|
| 390 |
-
continue
|
| 391 |
-
|
| 392 |
-
# 动态注入当前处理的视角ID
|
| 393 |
-
game[0]['id'] = j
|
| 394 |
-
encoder.process_game(game)
|
| 395 |
-
|
| 396 |
-
worker_status["urls_processed"] += 1
|
| 397 |
except Exception as e:
|
| 398 |
-
print(f"
|
| 399 |
traceback.print_exc()
|
| 400 |
-
|
| 401 |
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
|
|
|
|
|
|
| 410 |
|
| 411 |
-
if __name__ ==
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
|
|
|
| 1 |
import os
|
|
|
|
| 2 |
import json
|
| 3 |
+
import orjson
|
| 4 |
import pickle
|
| 5 |
import threading
|
| 6 |
+
import time
|
| 7 |
+
import uuid
|
| 8 |
+
import glob
|
| 9 |
+
import random
|
| 10 |
import traceback
|
|
|
|
| 11 |
import numpy as np
|
|
|
|
| 12 |
from datetime import datetime
|
| 13 |
+
import gradio as gr
|
|
|
|
|
|
|
|
|
|
| 14 |
from huggingface_hub import HfApi, hf_hub_download
|
| 15 |
|
| 16 |
# ==========================================
|
| 17 |
+
# [依赖导入]
|
| 18 |
# ==========================================
|
| 19 |
+
from riichienv import RiichiEnv, GameRule
|
| 20 |
|
| 21 |
+
# 导入两种不同架构的加载函数
|
| 22 |
+
from model3pLOCAL import load_model as load_model_teacher # 775 维 (Teacher: 9070)
|
| 23 |
+
from model3pNEW import load_model as load_model_student # 1012 维 (Student: 57000)
|
| 24 |
|
| 25 |
+
# 导入纯 Python 版的状态机,用于提取 1012 维特征
|
| 26 |
try:
|
| 27 |
from libriichiSanma import state as sanma_state
|
| 28 |
except ImportError:
|
| 29 |
import libriichi as sanma_state
|
| 30 |
|
| 31 |
# ==========================================
|
| 32 |
+
# [全局配置]
|
| 33 |
# ==========================================
|
| 34 |
+
DATASET_REPO = "AstraNASA/tenhou-scc" # 存放打包好的 DAgger 数据的仓库
|
| 35 |
+
MODEL_REPO_ID = "ffzeroHua/Riichi-Model-Repo"
|
| 36 |
+
HF_TOKEN = os.getenv("HF_TOKEN")
|
| 37 |
+
WORKER_ID = os.getenv("WORKER_ID", str(uuid.uuid4())[:6])
|
| 38 |
+
|
| 39 |
+
# 🚀 对战双方权重
|
| 40 |
+
STUDENT_MODEL = "StudentSanma_Distilled_Step57000.pth"
|
| 41 |
+
TEACHER_MODEL = "Elite4z9070.pth"
|
| 42 |
|
| 43 |
+
# 动作空间映射
|
| 44 |
MASK_3P = [
|
| 45 |
"1m", "2m", "3m", "4m", "5m", "6m", "7m", "8m", "9m",
|
| 46 |
"1p", "2p", "3p", "4p", "5p", "6p", "7p", "8p", "9p",
|
|
|
|
| 49 |
'5mr', '5pr', '5sr',
|
| 50 |
'reach', 'pon', 'kan', 'nukidora', 'hora', 'ryukyoku', 'none'
|
| 51 |
]
|
|
|
|
| 52 |
NONE_CODE = MASK_3P.index('none')
|
| 53 |
KAN_CODE = MASK_3P.index('kan')
|
|
|
|
| 54 |
|
| 55 |
+
# UI 状态监控
|
| 56 |
worker_status = {
|
| 57 |
+
"games_played": 0,
|
| 58 |
+
"records_extracted": 0,
|
| 59 |
+
"chunks_uploaded": 0,
|
| 60 |
+
"status": "Starting DAgger Factory..."
|
|
|
|
|
|
|
| 61 |
}
|
| 62 |
|
| 63 |
+
api = HfApi(token=HF_TOKEN) if HF_TOKEN else None
|
| 64 |
+
EVAL_RUNNING = True
|
| 65 |
+
|
| 66 |
# ==========================================
|
| 67 |
+
# [辅助函数]
|
| 68 |
# ==========================================
|
| 69 |
+
def sync_models_from_hub():
|
| 70 |
+
if HF_TOKEN and "你的用户名" not in MODEL_REPO_ID:
|
| 71 |
+
print(f"☁️ 正在从 Hub 拉取模型...")
|
| 72 |
+
hf_hub_download(repo_id=MODEL_REPO_ID, filename=STUDENT_MODEL, repo_type="model", local_dir=".", token=HF_TOKEN)
|
| 73 |
+
hf_hub_download(repo_id=MODEL_REPO_ID, filename=TEACHER_MODEL, repo_type="model", local_dir=".", token=HF_TOKEN)
|
| 74 |
+
print("✅ 模型就绪!")
|
|
|
|
|
|
|
| 75 |
|
| 76 |
+
def patch_event_fast(event_str):
|
| 77 |
+
if '"kita"' in event_str:
|
| 78 |
+
event_str = event_str.replace('"kita"', '"nukidora"')
|
| 79 |
+
if '"start_kyoku"' in event_str or '"deltas"' in event_str:
|
| 80 |
+
event = orjson.loads(event_str)
|
| 81 |
+
if event.get('type') == 'start_kyoku':
|
| 82 |
+
scores = event.setdefault('scores', [])
|
| 83 |
+
while len(scores) < 4: scores.append(0)
|
| 84 |
+
tehais = event.setdefault('tehais', [])
|
| 85 |
+
while len(tehais) < 4: tehais.append(["?" for _ in range(13)])
|
| 86 |
+
if 'deltas' in event:
|
| 87 |
+
deltas = event['deltas']
|
| 88 |
+
while len(deltas) < 4: deltas.append(0)
|
| 89 |
+
return orjson.dumps(event).decode('utf-8')
|
| 90 |
+
return event_str
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
def patch_resp_fast(resp_str):
|
| 93 |
+
if not resp_str: return resp_str
|
| 94 |
+
return resp_str.replace('"nukidora"', '"kita"')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
def action_to_label(who, action_dict):
|
| 97 |
+
if action_dict is None: return NONE_CODE
|
| 98 |
+
if action_dict.get('actor') != who or action_dict.get('type') == 'tsumo': return NONE_CODE
|
| 99 |
+
t = action_dict['type']
|
| 100 |
+
if t == 'dahai': return MASK_3P.index(action_dict['pai'])
|
| 101 |
+
if t in ('daiminkan', 'ankan', 'kakan'): return KAN_CODE
|
| 102 |
+
if t in MASK_3P: return MASK_3P.index(t)
|
| 103 |
+
return NONE_CODE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
# ==========================================
|
| 106 |
+
# [DAgger 特征打包器]
|
| 107 |
# ==========================================
|
| 108 |
+
class DAggerEncoder:
|
| 109 |
+
def __init__(self, chunk_size=2048):
|
| 110 |
self.chunk_size = chunk_size
|
| 111 |
+
self.inputs, self.outputs = [], []
|
| 112 |
+
self.local_pool_dir = f"dagger_pool_{WORKER_ID}"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
os.makedirs(self.local_pool_dir, exist_ok=True)
|
| 114 |
|
| 115 |
+
def append(self, obs_s, mask_s, label):
|
| 116 |
+
self.inputs.append({
|
| 117 |
+
"obs_student": obs_s,
|
| 118 |
+
"mask_student": mask_s
|
| 119 |
+
})
|
| 120 |
+
self.outputs.append(label)
|
| 121 |
+
worker_status["records_extracted"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
|
| 123 |
+
if len(self.inputs) >= self.chunk_size:
|
| 124 |
+
self.save_and_upload()
|
| 125 |
+
|
| 126 |
+
def save_and_upload(self):
|
| 127 |
+
filename = f"chunk_dagger_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{WORKER_ID}.pkl"
|
| 128 |
filepath = os.path.join(self.local_pool_dir, filename)
|
| 129 |
|
| 130 |
+
# 保存本地
|
| 131 |
with open(filepath, 'wb') as f:
|
| 132 |
+
pickle.dump({'inputs': self.inputs, 'outputs': self.outputs}, f)
|
|
|
|
|
|
|
| 133 |
|
|
|
|
| 134 |
self.inputs.clear()
|
| 135 |
self.outputs.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
+
# 上传到 Hub (使用独立的线程防止阻塞打牌)
|
| 138 |
+
if api and DATASET_REPO:
|
| 139 |
+
threading.Thread(target=self._upload_task, args=(filepath, filename)).start()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
def _upload_task(self, filepath, filename):
|
| 142 |
+
try:
|
| 143 |
+
api.upload_file(
|
| 144 |
+
path_or_fileobj=filepath,
|
| 145 |
+
path_in_repo=f"dagger_chunks/{filename}", # 传到 dagger_chunks 文件夹下
|
| 146 |
+
repo_id=DATASET_REPO,
|
| 147 |
+
repo_type="dataset"
|
| 148 |
+
)
|
| 149 |
+
worker_status["chunks_uploaded"] += 1
|
| 150 |
+
os.remove(filepath)
|
| 151 |
+
print(f"☁️ [DAgger] 成功上传数据块: {filename}")
|
| 152 |
+
except Exception as e:
|
| 153 |
+
print(f"⚠️ 上传失败: {e}")
|
| 154 |
+
|
| 155 |
+
# ==========================================
|
| 156 |
+
# [单局 DAgger 引擎]
|
| 157 |
+
# ==========================================
|
| 158 |
+
def play_dagger_game(encoder: DAggerEncoder):
|
| 159 |
+
env = RiichiEnv(game_mode="3p-red-half", rule=GameRule.default_tenhou())
|
| 160 |
+
|
| 161 |
+
# 每次开局,必须重新实例化 Bot,防止底层 Rust 状态机污染
|
| 162 |
+
# Student 负责打牌,Teacher 负责指点
|
| 163 |
+
student_bots = {i: load_model_student(i, STUDENT_MODEL) for i in range(3)}
|
| 164 |
+
teacher_bots = {i: load_model_teacher(i, TEACHER_MODEL) for i in range(3)}
|
| 165 |
+
|
| 166 |
+
# 纯 Python 状态机,负责截获 Student 视角的 1012维 Obs
|
| 167 |
+
python_states = {i: sanma_state.PlayerState(i) for i in range(3)}
|
| 168 |
+
|
| 169 |
+
obs_dict = env.reset()
|
| 170 |
+
|
| 171 |
+
while not env.done():
|
| 172 |
+
actions_to_env = {}
|
| 173 |
|
| 174 |
+
for pid, env_obs in obs_dict.items():
|
| 175 |
+
student_final_action = None
|
| 176 |
+
|
| 177 |
+
for event_str in env_obs.new_events():
|
| 178 |
+
event_patched = patch_event_fast(event_str)
|
| 179 |
|
| 180 |
+
# 1. 更新 Python 状态机以生成 1012 维特征
|
| 181 |
+
cans = python_states[pid].update(event_patched)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
|
| 183 |
+
# 2. 幽灵教师思考 (但不参与实战控制)
|
| 184 |
+
teacher_resp_str = teacher_bots[pid].react(event_patched)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
|
| 186 |
+
# 3. 如果到了需要决策的节点,进行 DAgger 抓取
|
| 187 |
+
if cans.can_act:
|
| 188 |
+
obs_s, mask_s = python_states[pid].encode_obs(4, False)
|
| 189 |
+
|
| 190 |
+
try:
|
| 191 |
+
teacher_resp = patch_resp_fast(teacher_resp_str)
|
| 192 |
+
action_dict = json.loads(teacher_resp) if teacher_resp else None
|
| 193 |
+
label_idx = action_to_label(pid, action_dict)
|
| 194 |
+
|
| 195 |
+
# 只有当存在有效决策空间时,才记录
|
| 196 |
+
if int(np.count_nonzero(mask_s)) > 1:
|
| 197 |
+
encoder.append(obs_s, mask_s, label_idx)
|
| 198 |
+
except Exception:
|
| 199 |
+
pass
|
| 200 |
|
| 201 |
+
# 4. 学生思考 (真正掌控局面)
|
| 202 |
+
student_resp_str = student_bots[pid].react(event_patched)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
|
| 204 |
+
# 提取学生最后一次有效响应,喂给环境
|
| 205 |
+
if student_resp_str and '"type":"none"' not in student_resp_str.replace(' ', ''):
|
| 206 |
+
student_final_action = env_obs.select_action_from_mjai(patch_resp_fast(student_resp_str))
|
| 207 |
|
| 208 |
+
if student_final_action is None:
|
| 209 |
+
student_final_action = env_obs.select_action_from_mjai('{"type": "none"}')
|
| 210 |
+
|
| 211 |
+
actions_to_env[pid] = student_final_action
|
| 212 |
+
|
| 213 |
+
# 环境按照学生(劣势)的动作继续
|
| 214 |
+
obs_dict = env.step(actions_to_env)
|
| 215 |
+
|
| 216 |
+
worker_status["games_played"] += 1
|
| 217 |
|
| 218 |
# ==========================================
|
| 219 |
+
# [后台循环]
|
| 220 |
# ==========================================
|
| 221 |
+
def background_dagger_loop():
|
| 222 |
+
sync_models_from_hub()
|
| 223 |
+
encoder = DAggerEncoder(chunk_size=2048)
|
| 224 |
+
worker_status["status"] = "Generating DAgger Data..."
|
| 225 |
+
|
| 226 |
+
# 限制单线程跑牌,防止内存爆炸,反正是在 HF Space 白嫖
|
| 227 |
+
while EVAL_RUNNING:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
try:
|
| 229 |
+
play_dagger_game(encoder)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
except Exception as e:
|
| 231 |
+
print(f"Game crashed: {e}")
|
| 232 |
traceback.print_exc()
|
| 233 |
+
time.sleep(1)
|
| 234 |
|
| 235 |
+
# ==========================================
|
| 236 |
+
# [前端 UI]
|
| 237 |
+
# ==========================================
|
| 238 |
+
def get_stats():
|
| 239 |
+
md = f"""
|
| 240 |
+
### ⚔️ DAgger 数据工厂运行中...
|
| 241 |
+
- **🏭 工厂状态:** {worker_status['status']}
|
| 242 |
+
- **🤖 掌控对局:** 3只 `{STUDENT_MODEL}` (1012 维)
|
| 243 |
+
- **👻 幽灵教练:** 3只 `{TEACHER_MODEL}` (9070 黑盒)
|
| 244 |
+
---
|
| 245 |
+
- **🀄 已完成对局:** {worker_status['games_played']} 局
|
| 246 |
+
- **🧠 提取的高质量决策:** {worker_status['records_extracted']} 条
|
| 247 |
+
- **📦 已上传数据块 (Chunks):** {worker_status['chunks_uploaded']} 个
|
| 248 |
+
- **🌐 节点 ID:** `{WORKER_ID}`
|
| 249 |
+
|
| 250 |
+
*提示:在你的 Colab 微调程序中,只需将 target_prefix 设为 `"dagger_chunks"` 即可开始训练!*
|
| 251 |
+
"""
|
| 252 |
+
return md
|
| 253 |
|
| 254 |
+
with gr.Blocks() as demo:
|
| 255 |
+
gr.Markdown("# 🀄 Mahjong DAgger 数据提纯引擎")
|
| 256 |
+
stats_output = gr.Markdown("⏳ 正在初始化 DAgger 引擎并拉取模型...")
|
| 257 |
+
demo.load(fn=get_stats, inputs=None, outputs=stats_output)
|
| 258 |
+
gr.Timer(5).tick(fn=get_stats, inputs=None, outputs=stats_output)
|
| 259 |
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
t = threading.Thread(target=background_dagger_loop, daemon=True)
|
| 262 |
+
t.start()
|
| 263 |
+
demo.queue().launch(server_name="0.0.0.0", server_port=7860)
|