import csv import gzip import pickle import hashlib import os import shutil import uuid import multiprocessing as mp from tqdm import tqdm import numpy as np import lmdb import requests from src.utils import utils ctx = mp.get_context("spawn") # safer with LMDB/pickle Process = ctx.Process Queue = ctx.Queue def _is_test(draft_id: str, pct=10): h = int.from_bytes(hashlib.sha1(draft_id.encode()).digest()[:4], "little") return h % 100 < pct def lmdb_writer(db_path, q: Queue, commit_every=200_000, map_size=int(16e9)): env = lmdb.open(db_path, map_size=map_size, subdir=True, lock=True, sync=False, metasync=False, readahead=False, writemap=False) # writemap=False to avoid big prealloc on some FS txn, n = env.begin(write=True), 0 while True: item = q.get() if item is None: break key, value = item try: txn.put(key, value) except lmdb.MapFullError: txn.abort() env.set_mapsize(env.info()["map_size"] * 2) txn = env.begin(write=True) txn.put(key, value) n += 1 if n % commit_every == 0: txn.commit(); txn = env.begin(write=True) txn.commit() with env.begin(write=True) as t2: t2.put(b"__len__", str(n).encode()) env.sync(); env.close() def get_name(obstructed_name): if 'pack_card_' in obstructed_name: return obstructed_name.replace('pack_card_','') elif 'pool_' in obstructed_name: return obstructed_name.replace('pool_','') else: print(obstructed_name) raise ValueError('Invalid name') def encode_sample(sample): return pickle.dumps(sample, protocol=pickle.HIGHEST_PROTOCOL) def stream_csv(csv_path, train_q, test_q): with open(csv_path, "r", newline="", buffering=16*1024*1024) as f: reader = csv.reader(f) header = next(reader) pack_idx = [i for i,c in enumerate(header) if c.startswith("pack_card_")] pool_idx = [i for i,c in enumerate(header) if c.startswith("pool_")] pick_idx = header.index("pick") wins_idx = header.index("event_match_wins") loss_idx = header.index("event_match_losses") try: user_games = header.index("user_n_games_bucket") except: user_games = header.index("user_n_matches_bucket") try: user_wr = header.index("user_game_win_rate_bucket") except: try: user_wr = header.index("user_match_win_rate_bucket") except: user_wr = None draft_idx = 2 # your draft id column pack_names = {i: header[i].replace("pack_card_","") for i in pack_idx} pool_names = {i: header[i].replace("pool_","") for i in pool_idx} for row in tqdm(reader): try: draft_id = row[draft_idx] positive = row[pick_idx] except: print(f"Skipping row with missing draft_id or pick: {row}") continue negatives = [] for i in pack_idx: s = row[i] if s and s != "0": cnt = int(s) name = pack_names[i] if name != positive: negatives.extend([name]*cnt) anchor = [] for i in pool_idx: s = row[i] if s and s != "0": anchor.extend([pool_names[i]]*int(s)) wins = int(row[wins_idx]) losses = int(row[loss_idx]) u_g = int(row[user_games]) u_wr = float(row[user_wr]) if user_wr and row[user_wr] else 0.0 payload = encode_sample((positive, negatives, anchor, wins, losses, u_g, u_wr)) key = os.urandom(16) (test_q if _is_test(draft_id) else train_q).put((key, payload)) _17LANDS_URL = ( "https://17lands-public.s3.amazonaws.com/analysis_data/draft_data/" "draft_data_public.{set_tag}.{draft_format}.csv.gz" ) def download_17lands(set_tag, raw_data_folder, draft_format="PremierDraft"): url = _17LANDS_URL.format(set_tag=set_tag, draft_format=draft_format) out_dir = os.path.join(raw_data_folder, set_tag) os.makedirs(out_dir, exist_ok=True) csv_path = os.path.join(out_dir, f"{set_tag}_{draft_format}.csv") if os.path.exists(csv_path): print(f"{csv_path} already exists, skipping download") return print(f"Downloading {url}") response = requests.get(url, stream=True) if response.status_code == 404: raise FileNotFoundError( f"17lands data not found for {set_tag}/{draft_format}.\n" f"URL tried: {url}\n" f"Check https://17lands.com/public/data for the correct set code and format." ) response.raise_for_status() gz_path = csv_path + ".gz" total = int(response.headers.get("content-length", 0)) with open(gz_path, "wb") as f, tqdm(total=total, unit="B", unit_scale=True, desc=f"Downloading {set_tag}") as bar: for chunk in response.iter_content(chunk_size=1 << 17): f.write(chunk) bar.update(len(chunk)) print(f"Decompressing {gz_path}") with gzip.open(gz_path, "rb") as f_in, open(csv_path, "wb") as f_out: shutil.copyfileobj(f_in, f_out) os.remove(gz_path) print(f"Saved to {csv_path}") def stream_csv_trajectories(csv_path, train_q, test_q): """Buffer all picks by draft_id, then write one trajectory per draft.""" drafts = {} with open(csv_path, "r", newline="", buffering=16*1024*1024) as f: reader = csv.reader(f) header = next(reader) pack_idx = [i for i,c in enumerate(header) if c.startswith("pack_card_")] pick_idx = header.index("pick") wins_idx = header.index("event_match_wins") loss_idx = header.index("event_match_losses") draft_idx = 2 try: user_games = header.index("user_n_games_bucket") except: user_games = header.index("user_n_matches_bucket") try: user_wr = header.index("user_game_win_rate_bucket") except: try: user_wr = header.index("user_match_win_rate_bucket") except: user_wr = None if "expansion_pick_number" in header: order_idx = header.index("expansion_pick_number") pack_num_idx = None pick_num_idx = None else: order_idx = None pack_num_idx = header.index("pack_number") pick_num_idx = header.index("pick_number") maindeck_idx = header.index("pick_maindeck_rate") if "pick_maindeck_rate" in header else None pack_names = {i: header[i].replace("pack_card_", "") for i in pack_idx} for row in tqdm(reader): try: draft_id = row[draft_idx] positive = row[pick_idx] except: continue step = int(row[order_idx]) if order_idx is not None \ else int(row[pack_num_idx]) * 15 + int(row[pick_num_idx]) # pick first, then the rest of the pack pack_cards = [positive] for i in pack_idx: s = row[i] if s and s != "0": name = pack_names[i] if name != positive: pack_cards.extend([name] * int(s)) wins = int(row[wins_idx]) losses = int(row[loss_idx]) u_g = int(row[user_games]) u_wr = float(row[user_wr]) if user_wr and row[user_wr] else 0.0 in_md = float(row[maindeck_idx]) if maindeck_idx is not None and row[maindeck_idx] else 0.0 if positive in ('Plains', 'Island', 'Swamp', 'Mountain', 'Forest'): in_md = 0.0 if draft_id not in drafts: drafts[draft_id] = {'steps': {}, 'wins': wins, 'losses': losses, 'u_g': u_g, 'u_wr': u_wr} if step not in drafts[draft_id]['steps']: drafts[draft_id]['steps'][step] = (pack_cards, in_md) for draft_id, data in drafts.items(): sorted_steps = sorted(data['steps'].items()) sequence = [pack for _, (pack, _) in sorted_steps] in_maindeck = [md for _, (_, md) in sorted_steps] if not sequence: continue payload = encode_sample((sequence, in_maindeck, data['wins'], data['losses'], data['u_g'], data['u_wr'])) (test_q if _is_test(draft_id) else train_q).put((draft_id.encode(), payload)) def all_preprocessing_for_set(set_tag, raw_data_folder, out_folder): base = os.path.join(raw_data_folder, set_tag) files = [os.path.join(base, f) for f in os.listdir(base) if f.endswith(".csv")] out = os.path.join(out_folder, set_tag) for name in ('train.lmdb', 'test.lmdb'): p = os.path.join(out, name) if os.path.exists(p): shutil.rmtree(p) os.makedirs(out, exist_ok=True) train_q = Queue(maxsize=50_000) test_q = Queue(maxsize=50_000) train_writer = Process(target=lmdb_writer, args=(os.path.join(out, "train.lmdb"), train_q)) test_writer = Process(target=lmdb_writer, args=(os.path.join(out, "test.lmdb"), test_q)) train_writer.start(); test_writer.start() for csv_path in files: print(f"Processing {os.path.basename(csv_path)}") stream_csv_trajectories(csv_path, train_q, test_q) train_q.put(None); test_q.put(None) train_writer.join(); test_writer.join() if __name__ == "__main__": config = utils.load_config('src/configs/config.yaml') raw_data_folder = config['raw_data_folder'] out_folder = config['super_folder'] os.makedirs(out_folder, exist_ok=True) for folder in os.listdir(raw_data_folder): if folder not in os.listdir(out_folder) and folder != "cube": print(f"Processing set {folder}") all_preprocessing_for_set(folder, raw_data_folder, out_folder)