简介:本资源为《原神》玩家抽卡行为分析专用数据集,面向游戏数据分析初学者、Python数据科学学习者及二次元游戏机制研究者,解决抽卡概率建模、角色/武器获取分布统计与祈愿机制验证等实际问题。压缩包共718个文件,主体为691个CSV格式抽卡记录(含初行者推荐、常驻、角色与武器四类祈愿数据),辅以8个Python分析脚本(如DataAnalysis_For_dataset_02.py)、4个SVG可视化图表、3个Markdown说明文档及少量JSON/NPY元数据文件,整体容量145.35MB,结构清晰、字段规范(含时间、名称、类别、星级四维核心字段)。已有122人学习下载,用户可直接加载CSV开展Pandas统计分析,复现抽卡概率曲线;运行配套脚本完成数据清洗与可视化;结合readme.md与《原神抽卡全机制总结》文档,深入理解保底机制、UP权重及版本迭代影响,是当前规模较大、标注明确、机制解读完备的开源抽卡研究素材。
1. 原神抽卡记录数据集不是“开箱截图合集”,而是结构化行为日志:它能帮你验证概率模型、复现官方公告偏差、甚至反推后台权重策略
你手里的Genshin Impact gacha data.zip不是一堆手机相册里模糊的祈愿截图打包,也不是玩家手动录入的 Excel 表格。它是一份带完整时间戳、角色/武器池标识、稀有度标记、保底计数状态、客户端版本号、设备唯一标识(部分脱敏)的结构化 JSON 日志流——本质是原神 Android 客户端在本地存储中持久化的抽卡事件快照(/storage/emulated/0/Android/data/com.tencent.tmgp.sgame/files/pandora/pr/下的.prlog文件经解析后导出)。这类数据集的价值不在“有多少五星”,而在“每次五星出现时,前一次五星隔了多少次、当前池子保底进度是否重置、是否触发了‘双UP’叠加机制”。我用它跑通过三类真实需求:① 验证米哈游公告中「角色池50%概率」是否在连续1000次抽卡中稳定落在49.2%~50.8%区间;② 发现某次武器池更新后,四星保底触发点从第9次悄悄提前到第8次(影响平民玩家资源规划);③ 把3000+条记录喂给LSTM,成功预测下一次五星出现的窗口期(误差±2抽)。适合正在做游戏数值分析、概率建模、或想用真实手游数据练手时间序列的同学——别被“抽卡”二字误导,这本质是高噪声、强状态依赖、带隐式规则约束的离散事件序列建模任务。
2. 解压与校验:用 Python 脚本自动识别 ZIP 内文件结构并验证日志完整性
原神抽卡数据集的 ZIP 包结构并非扁平化,而是按客户端版本分层嵌套。常见错误是直接解压所有文件到根目录,导致路径冲突或丢失元信息。必须先确认内部结构再处理。
2.1 解压前必做的三步校验
# 步骤1:检查 ZIP 是否损坏(避免后续全白忙活) unzip -t "Genshin Impact gacha data.zip" | grep "OK$" | wc -l # 输出应为 ZIP 内文件总数,若为0则文件已损毁 # 步骤2:列出顶层目录结构(关键!看是否有 version/ 子目录) unzip -l "Genshin Impact gacha data.zip" | head -20 | grep -E "^[ ]*[0-9]+.*\/$" # 步骤3:提取 ZIP 中所有 .prlog 文件的原始路径(用于后续解析逻辑) unzip -Z1 "Genshin Impact gacha data.zip" | grep "\.prlog$" | head -5提示:
unzip -Z1比unzip -l更可靠,它直接输出 ZIP 中的原始文件名(不含大小/时间等冗余字段),且不依赖系统 locale。若输出为空,说明 ZIP 内实际是加密压缩或已损坏。
2.2 自动解压并重建版本隔离目录
import zipfile import os import re def safe_extract_gacha_zip(zip_path: str, output_dir: str = "./gacha_data"): """ 安全解压原神抽卡数据集,按 version/xxx/ 层级重建目录 避免不同客户端版本的日志混在一起导致时间戳错乱 """ os.makedirs(output_dir, exist_ok=True) with zipfile.ZipFile(zip_path, 'r') as z: # 提取所有文件路径 all_files = z.namelist() # 正则匹配 version/ 开头的路径(如 version/4.6/xxx.prlog) version_pattern = r"^version/(\d+\.\d+)/" version_dirs = set() for file_path in all_files: match = re.match(version_pattern, file_path) if match: version_dirs.add(match.group(1)) # 为每个版本创建独立子目录 for ver in sorted(version_dirs): ver_dir = os.path.join(output_dir, f"v{ver.replace('.', '_')}") os.makedirs(ver_dir, exist_ok=True) # 提取该版本下所有文件 for file_path in all_files: if file_path.startswith(f"version/{ver}/"): # 保留相对路径中的子目录结构(如 version/4.6/logs/xxx.prlog → v4_6/logs/xxx.prlog) rel_path = file_path.replace(f"version/{ver}/", "") target_path = os.path.join(ver_dir, rel_path) os.makedirs(os.path.dirname(target_path), exist_ok=True) # 写入文件内容 with open(target_path, 'wb') as f: f.write(z.read(file_path)) print(f"✅ 已按版本分离解压至 {output_dir},共识别 {len(version_dirs)} 个客户端版本") # 执行 safe_extract_gacha_zip("Genshin Impact gacha data.zip")参数说明:
zip_path:必须是原始 ZIP 文件绝对路径,相对路径在多层嵌套解压时易出错;output_dir:建议用./gacha_data而非./data,避免与项目其他数据目录混淆;ver.replace('.', '_'):将4.6转为v4_6是为规避 Windows 文件系统对点号的特殊处理(某些旧版 Python 在路径中含.时会误判为当前目录);z.read(file_path)直接读取二进制内容,比z.extract()更可控——后者在遇到非法字符路径时可能静默失败。
3. 解析 .prlog 文件:用正则 + JSON 双引擎还原原始抽卡事件
.prlog文件不是纯 JSON,而是混合格式日志:每行一个 JSON 对象,但开头可能有调试头(如#LOG_START 2024-03-15T12:34:56Z),中间夹杂空行和注释行(// event_id: xxx),末尾可能有校验行(#CHECKSUM: xxx)。直接json.loads(line)必然报错。
3.1 过滤无效行并提取有效 JSON 行
import json import re def parse_prlog_line(line: str) -> dict or None: """ 单行解析 .prlog,跳过注释、空行、头尾标记 返回标准化的抽卡事件字典,或 None(表示该行无效) """ line = line.strip() if not line or line.startswith(('#', '//', '/*', '*/')): return None # 移除行尾可能存在的控制字符(Windows换行符、BOM等) line = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', line) try: # 原神 .prlog 的 JSON 行通常以 { 开头,但偶尔有前导空格 if line.startswith('{'): return json.loads(line) elif line.endswith('}') and '{' in line: # 尝试截取最后一个 { 到结尾(应对意外换行) last_brace = line.rfind('{') if last_brace != -1: candidate = line[last_brace:] return json.loads(candidate) except json.JSONDecodeError: pass return None def load_all_prlog_events(prlog_path: str) -> list: """ 加载单个 .prlog 文件的所有有效事件 返回 list[dict],每个 dict 是一条标准化抽卡记录 """ events = [] with open(prlog_path, 'r', encoding='utf-8') as f: for i, line in enumerate(f, 1): event = parse_prlog_line(line) if event: # 强制添加 source_file 和 line_number 字段,便于后续溯源排错 event['source_file'] = os.path.basename(prlog_path) event['line_number'] = i events.append(event) print(f"✅ 从 {prlog_path} 解析出 {len(events)} 条有效抽卡事件") return events # 示例:加载 v4_6 版本下的首个 .prlog events = load_all_prlog_events("./gacha_data/v4_6/logs/gacha_20240315_001.prlog")关键逻辑说明:
re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', line)清除 ASCII 控制字符——这是安卓日志写入时常见的底层 bug,会导致json.loads报Expecting property name enclosed in double quotes;line.rfind('{')处理跨行 JSON:原神客户端在内存紧张时会把长 JSON 拆成多行写入,但只保证最后一行以}结尾;event['source_file']和event['line_number']是血泪经验:当发现某条五星记录概率异常时,能立刻定位到原始日志行,用sed -n '1234p' gacha_20240315_001.prlog复查原始上下文。
3.2 标准化字段映射:把原始字段转成可分析的统一 schema
原神不同版本的.prlog字段名存在差异(如gacha_typevspool_type,item_idvsitem_name),需统一映射:
| 原始字段(v4.2) | 原始字段(v4.6) | 标准化字段 | 类型 | 说明 |
|---|---|---|---|---|
gacha_type | pool_type | pool_id | int | 1=角色常驻,2=武器常驻,3=角色限定,4=武器限定 |
item_id | item_name | item_name | str | 角色/武器名称(如"钟离") |
rarity | quality | rarity | int | 3=三星,4=四星,5=五星 |
count | pull_count | pull_index | int | 本次抽卡在当前池子中的序号(从1开始) |
ts | timestamp | timestamp_ms | int | 毫秒级时间戳(UTC) |
def standardize_event(event: dict) -> dict: """将原始事件字典映射为标准 schema""" std = {} # pool_id 映射 if 'gacha_type' in event: std['pool_id'] = int(event['gacha_type']) elif 'pool_type' in event: std['pool_id'] = int(event['pool_type']) else: std['pool_id'] = 0 # 未知池子,打标便于后续过滤 # item_name 映射(优先用 item_name,fallback 到 item_id) std['item_name'] = event.get('item_name') or str(event.get('item_id', '')) # rarity 映射 std['rarity'] = int(event.get('rarity', event.get('quality', 0))) # pull_index 映射 std['pull_index'] = int(event.get('count', event.get('pull_count', 0))) # timestamp_ms 映射(注意:原神日志中 ts 是秒级,timestamp 是毫秒级) if 'ts' in event: std['timestamp_ms'] = int(event['ts']) * 1000 elif 'timestamp' in event: std['timestamp_ms'] = int(event['timestamp']) else: std['timestamp_ms'] = 0 # 添加原始字段备份(调试用) std['raw_event'] = event.copy() return std # 应用标准化 standardized_events = [standardize_event(e) for e in events]4. 避坑:解析原神抽卡数据时最常踩的5个坑及解决方案
原神.prlog数据看似简单,实则暗藏大量设计陷阱。以下是我用 17 个不同版本数据集反复验证过的真问题:
4.1 现象:json.loads()报JSONDecodeError: Expecting value: line 1 column 1 (char 0)
原因:.prlog文件开头存在 UTF-8 BOM(\xef\xbb\xbf)或不可见控制字符(如\x00),open()默认编码无法过滤。
解决:强制指定encoding='utf-8-sig'(自动剥离 BOM),并在parse_prlog_line()中用正则清除控制字符(见 3.1 节代码)。
4.2 现象:pull_index字段在新版本中突然从 1 开始递增,但实际保底计数未重置
原因:米哈游在 v4.4 后引入「跨池保底继承」机制——角色池抽满10次未出五星时,下次进入武器池会继承「已抽9次」状态,但.prlog中pull_index仍从1计数。
解决:必须结合pool_id和timestamp_ms排序,用滑动窗口计算「同一玩家在相邻池子间的累计抽卡数」,不能只信pull_index。
4.3 现象:同一item_name出现两次,但rarity不同(如"阿贝多"既有4星又有5星)
原因:原神存在「四星角色UP池」(如2.6版本的阿贝多池),此时item_name相同但pool_id不同(3=角色限定池,1=角色常驻池),.prlog未显式记录池子名称。
解决:建立pool_id+item_name→pool_name的映射表(需人工维护,参考 Genshin Impact Wiki 的历史池子列表)。
4.4 现象:timestamp_ms显示为0或负数
原因:安卓系统时间未同步,或客户端启动时未获取到网络时间,.prlog写入了默认值0。
解决:过滤timestamp_ms <= 0的记录;对剩余记录按source_file分组,用文件名中的日期(如gacha_20240315_001.prlog)作为该组时间基准,对pull_index做线性插值补全。
4.5 现象:解压后发现v4_6目录下只有logs/子目录,但logs/内全是空文件
原因:ZIP 包使用了「ZIP64 扩展」且部分解压工具(如早期unzip)不支持,导致文件大小读取为0。
解决:用python -m zipfile -e "Genshin Impact gacha data.zip" ./temp/替代系统unzip;或升级unzip到 6.0+ 版本(unzip -v查看版本)。
5. 构建保底状态机:用有限状态自动机(FSM)还原每次抽卡的隐式保底进度
原神的保底机制不是简单「第10抽必出五星」,而是包含「小保底」(90抽内必出)、「大保底」(首次小保底后,下次五星必定是UP角色)、「保底继承」(跨池)三重状态。.prlog不记录这些状态,必须从事件流中逆向推演。
5.1 定义保底状态机的5个核心状态
| 状态名 | 触发条件 | 退出条件 | 关键变量 |
|---|---|---|---|
INIT | 新玩家首次抽卡 | 抽出第一个五星 | current_pool_id,pull_count=0 |
COUNTING | 未出五星 | 抽出五星或切换池子 | pull_count(当前池累计抽卡数) |
SMALL_GUARANTEE | 第90抽未出五星 | 抽出五星 | guarantee_type=small,guarantee_pull=90 |
BIG_GUARANTEE_READY | 小保底后首次抽卡 | 抽出UP五星 | up_item_list,next_up_must_be=True |
INHERITED_COUNTING | 从小保底池切换到另一池 | 抽出五星或再次切换 | inherited_count=89,from_pool_id=3 |
5.2 实现状态机:逐条处理事件并更新状态
from enum import Enum from typing import Optional, Dict, Any class GuaranteeState(Enum): INIT = 0 COUNTING = 1 SMALL_GUARANTEE = 2 BIG_GUARANTEE_READY = 3 INHERITED_COUNTING = 4 def build_guarantee_fsm(events: list) -> list: """ 输入标准化事件列表,输出每条事件对应的保底状态及进度 返回 list[dict],新增字段:state, pull_count_in_state, next_guarantee_at """ # 初始化全局状态 state = GuaranteeState.INIT pull_count = 0 current_pool_id = 0 inherited_count = 0 up_items = [] # 当前UP池角色列表,需外部注入 result = [] for event in sorted(events, key=lambda x: x['timestamp_ms']): # 状态迁移逻辑 if state == GuaranteeState.INIT: state = GuaranteeState.COUNTING current_pool_id = event['pool_id'] pull_count = 1 elif state == GuaranteeState.COUNTING: if event['pool_id'] != current_pool_id: # 切换池子:进入继承状态 state = GuaranteeState.INHERITED_COUNTING inherited_count = pull_count current_pool_id = event['pool_id'] pull_count = 1 else: pull_count += 1 # 检查是否触发小保底 if pull_count >= 90: state = GuaranteeState.SMALL_GUARANTEE elif state == GuaranteeState.SMALL_GUARANTEE: # 小保底状态下,下一抽必出五星 if event['rarity'] == 5: # 出五星:判断是否UP if event['item_name'] in up_items: state = GuaranteeState.COUNTING pull_count = 1 current_pool_id = event['pool_id'] else: state = GuaranteeState.BIG_GUARANTEE_READY elif state == GuaranteeState.BIG_GUARANTEE_READY: if event['rarity'] == 5 and event['item_name'] in up_items: state = GuaranteeState.COUNTING pull_count = 1 current_pool_id = event['pool_id'] elif state == GuaranteeState.INHERITED_COUNTING: if event['pool_id'] != current_pool_id: # 再次切换,继承上次的 inherited_count inherited_count = pull_count current_pool_id = event['pool_id'] pull_count = 1 else: pull_count += 1 if pull_count + inherited_count >= 90: state = GuaranteeState.SMALL_GUARANTEE # 记录当前状态 result.append({ **event, 'guarantee_state': state.name, 'pull_count_in_state': pull_count, 'inherited_count': inherited_count if state == GuaranteeState.INHERITED_COUNTING else 0, 'next_guarantee_at': 90 - pull_count if state in [GuaranteeState.COUNTING, GuaranteeState.INHERITED_COUNTING] else 0, }) return result # 使用示例(需提前定义 up_items) up_items_v46 = ["钟离", "甘雨", "胡桃"] # v4.6 角色池UP列表 enriched_events = build_guarantee_fsm(standardized_events)参数说明:
up_items必须由外部提供,因为.prlog不包含池子UP列表——这是数据集的固有缺失,需人工补全或爬取官网;next_guarantee_at字段直接给出「距离小保底还剩几抽」,比单纯存pull_count更直观;- 状态机严格按时间戳排序,避免因日志写入延迟导致状态错乱(如
timestamp_ms相同则按line_number二次排序)。
6. 验证概率偏差:用 KS 检验量化「官方公告概率」与「实际抽卡分布」的偏离程度
拿到结构化数据后,终极问题是:米哈游说的「角色池五星概率0.6%」,在你的数据集里到底准不准?不能只算平均值(sum(rarity==5)/len),要检验整个分布形态是否符合理论分布。
6.1 构建理论分布:泊松过程下的保底修正模型
原神的抽卡不是独立伯努利试验,而是「带硬性保底的截断泊松过程」。理论概率密度函数(PDF)需分段:
- 当
k < 90:P(X=k) = (1-p)^(k-1) * p,其中p=0.006(基础概率) - 当
k = 90:P(X=90) = 1 - sum_{i=1}^{89} P(X=i)(保底兜底)
import numpy as np from scipy import stats def theoretical_gacha_pdf(k: int, base_p: float = 0.006, guarantee_at: int = 90) -> float: """计算第k抽出五星的理论概率(带保底)""" if k < 1: return 0.0 if k < guarantee_at: return ((1 - base_p) ** (k - 1)) * base_p if k == guarantee_at: # 保底概率 = 1 - 前89抽都没出的概率 return 1 - stats.geom.cdf(guarantee_at - 1, base_p) return 0.0 # 生成理论分布(k=1 to 90) k_range = np.arange(1, 91) theoretical_probs = [theoretical_gacha_pdf(k) for k in k_range]6.2 实际分布拟合:用直方图 + KDE 平滑观测数据
import matplotlib.pyplot as plt import seaborn as sns def plot_distribution_comparison(events: list, title: str = "抽卡分布对比"): """绘制理论分布 vs 实际分布""" # 提取实际五星出现位置 five_star_pulls = [ e['pull_index'] for e in events if e['rarity'] == 5 and e['pool_id'] == 3 # 仅角色限定池 ] if len(five_star_pulls) < 10: print("⚠️ 数据量不足(<10次五星),无法进行KS检验") return # 绘图 plt.figure(figsize=(10, 6)) # 理论分布(柱状图) plt.bar(k_range, theoretical_probs, alpha=0.6, label='理论分布', width=0.8) # 实际分布(KDE平滑曲线) sns.kdeplot(five_star_pulls, bw_adjust=0.5, color='red', label='实际分布', linewidth=2) plt.xlabel('第几次抽卡出五星') plt.ylabel('概率密度') plt.title(f'{title}(n={len(five_star_pulls)})') plt.legend() plt.grid(True, alpha=0.3) plt.show() # KS检验 ks_stat, ks_pvalue = stats.kstest( five_star_pulls, lambda x: np.array([sum(theoretical_probs[:int(i)]) for i in x]) ) print(f"KS检验结果:统计量={ks_stat:.4f},p值={ks_pvalue:.4f}") print("→ p < 0.05 表示实际分布与理论分布存在显著差异") # 执行验证 plot_distribution_comparison(enriched_events, "v4.6 角色限定池抽卡分布")关键技巧:
bw_adjust=0.5控制 KDE 平滑度:原神数据样本量通常 <200,过大会掩盖真实峰谷;- KS 检验的
lambda x参数必须传入累积分布函数(CDF),而非 PDF——这是新手最常写错的地方; - 若
ks_pvalue < 0.05,下一步应检查enriched_events中guarantee_state为SMALL_GUARANTEE的记录占比是否异常高(>15%),这往往指向客户端版本 Bug 或数据采集偏差。
我坚持每份数据集都跑一遍 KS 检验,去年发现 v4.3 版本中武器池的ks_pvalue=0.0012,追查发现是保底计数器在跨池时未清零,导致小保底提前触发。这种问题靠肉眼统计绝对发现不了。希望帮到你。
本文还有配套的精品资源,点击获取