双色球机器学习流水线:特征工程、滚动回测与概率校准
简介:这份压缩包围绕“用机器学习分析双色球”展开,面向希望把数据处理、特征工程与模型训练串成完整实战流程的初学者和进阶学习者,也可作为彩票数据探索与随机性认知的案例材料。包内共29个文件,以16个Python脚本为主体,辅以SQL建表与入库脚本、JSON数据文件、Markdown说明、PNG图表及Pipfile依赖配置,压缩包约181KB,覆盖数据采集、清洗转换、训练日志和可视化等环节。已有385人学习下载,说明该主题具有一定参考热度。读者可从中了解历史开奖数据的组织方式、号码编码与特征构造思路、TensorFlow与PyTorch等框架的入门练习,以及模型训练评估和结果解释的基本路径;同时能体会彩票结果的随机性与不可预测性,把它当作机器学习项目练手和流程拆解的参考,而非中奖保证。
1. 先泼盆冷水:机器学习分析双色球,能做的和不能做的
有个做后端的朋友,拿两万多期历史开奖记录喂了个模型,回测里蓝球命中率刷到 11%,兴奋了三天;改成严格滚动预测后掉回 6%,跟闭眼瞎猜一模一样。这种落差在双色球数据分析里太常见了。这个 zip 里的项目并不承诺帮你中奖,它给的是一条能完整跑通的机器学习流水线:原始数据解析、特征工程、红球多标签建模、蓝球二分类与概率校准、滚动回测、结果落盘。适合三类人:想把 pandas、sklearn、LightGBM 从头到尾串一遍的练手者,想搞懂时间序列回测到底该怎么切窗口的人,以及拿它当数据分析教学样例的人。想靠它算出下一期号码的,趁早调头,前面只有玄学。
2. 工程结构拆解:一个 zip 里到底装了什么
解压之后不是一堆散落的脚本,而是一个可以反复重跑的小工程。我拆包后做的第一件事,是把目录树打印出来对着看职责边界。这类项目最容易失控的地方,是“数据清洗”和“特征构造”糊在一个文件里,改一行特征就顺带把解析逻辑弄崩。这个包在结构上把两者分开了,值得保留。
2.1 目录树与模块职责
lottery-ml/ ├── configs/ │ ├── default.yaml # 主配置:数据路径、特征窗口、模型参数 │ └── fast.yaml # 小样本快速验证用,跑通链路先跑这个 ├── data/ │ ├── raw/draws.csv # 原始开奖记录,一行一期 │ ├── interim/ # 清洗后的中间态,可随时删掉重生成 │ └── processed/ # 特征矩阵 + 标签,训练直接读这里 ├── src/ │ ├── data/build_dataset.py # 解析、校验、拆列 │ ├── features/feature.py # 遗漏值、冷热号、形态统计 │ ├── models/red_model.py # 红球 33 个二分类器 │ ├── models/blue_model.py # 蓝球二分类 + 校准 │ ├── backtest/rolling.py # 滚动窗口回测 │ ├── train.py # 训练入口 │ └── predict.py # 预测入口 ├── notebooks/ # 探索性分析,随便改不影响主链路 ├── outputs/ # 模型文件、指标 json、预测 csv └── requirements.txt| 模块 | 职责 | 你会改它的频率 |
|---|---|---|
src/data/ | 原始 csv 到结构化宽表 | 换数据源时才动 |
src/features/ | 特征矩阵构造 | 最高,天天改 |
src/models/ | 训练与推理 | 中,换模型时动 |
src/backtest/ | 评估协议 | 低,但改错最致命 |
configs/ | 参数外置 | 高,但只改数值 |
从职责切分能看出作者的设计意图:data层只保证“一行一期、字段干净”,features层只保证“每一行特征时间戳早于标签”,models层拿到的是纯数值矩阵。这样你换模型不影响数据管道,改特征也不影响回测协议。
2.2 数据层:把历史开奖记录落成一张宽表
原始数据一般是“期号、日期、红球一串、蓝球一个”的形态,红球用空格或逗号分隔,中间夹杂着补录的脏行。构建脚本干的就是把这种半结构化文本压成r1...r6 + blue的定长宽表,顺带做合法性校验。
import pandas as pd def parse_draw(raw: pd.DataFrame) -> pd.DataFrame: """把一行一条的原始开奖记录,拆成一行一期、6+1 列的结构化宽表""" # 红球先统一分隔符再切,兼容空格、逗号、顿号三种写法 reds = raw["red"].astype(str).str.replace(r"[,、]", " ", regex=True).str.split() rec = pd.DataFrame({ "issue": raw["issue"].astype(str).str.strip(), "date": pd.to_datetime(raw["date"], errors="coerce"), "reds": reds.apply(lambda x: sorted({int(v) for v in x if v.isdigit()})), "blue": pd.to_numeric(raw["blue"], errors="coerce"), }) # 三类脏数据直接丢:红球数量不对、有重复被去重后不足 6 个、日期解析失败 rec = rec[rec["reds"].apply(len) == 6] rec = rec.dropna(subset=["date", "blue"]) # 范围校验,防止把别的彩种数据混进来 rec = rec[rec["reds"].apply(lambda x: x[0] >= 1 and x[-1] <= 33)] rec = rec[(rec["blue"] >= 1) & (rec["blue"] <= 16)] # 期号去重后按时间升序,后面的滚动回测强依赖这个顺序 rec = rec.drop_duplicates(subset="issue").sort_values("date").reset_index(drop=True) # 展开成 r1..r6,升序排列后位置本身没有语义,这点后面建模会用到 rec[[f"r{i}" for i in range(1, 7)]] = pd.DataFrame(rec["reds"].tolist(), index=rec.index) return rec.drop(columns="reds")parse_draw里最关键的不是切分,而是最后那句sort_values("date")。后面的特征工程用shift和rolling表达“过去 N 期”,一旦行序乱了,整个时间轴就错位,模型看起来能训,指标全是假的。另一个细节是红球排序:双色球开奖时红球是按开奖顺序出的,但业务上组合无序,排序之后r1..r6只表示大小关系,不表示位置,这直接决定了 3.2 节为什么不能用六个独立的分类头。
跑完这一步,data/interim/draws.parquet就是全项目唯一的“事实表”,之后所有特征都从它派生。我一般会在这里加一个断言,把期号数量和头尾日期打印出来,跟原始文件对一眼再往下走。
2.3 配置与依赖:config.yaml 是唯一需要动的地方
参数不写死在代码里,是这个包比大多数练手项目讲究的一点。所有窗口长度、模型超参、回测折数都收在 yaml 里,调参不用改 Python,也就不会手滑改坏逻辑。
data: raw_path: data/raw/draws.csv start_issue: "2013001" # 早期数据字段缺失多,一般从这之后开始 min_rows: 500 # 样本太少直接拒绝训练,防止跑出无意义指标 features: lags: [1, 3, 5, 10, 30] # 滞后特征的回看阶数 hot_window: 30 # 热号统计窗口 cold_window: 100 # 冷号统计窗口 roll_windows: [10, 50] # 滚动均值/方差窗口 model: type: lightgbm params: num_leaves: 31 learning_rate: 0.03 n_estimators: 600 min_child_samples: 40 cv: scheme: rolling # 只支持 rolling,random 会泄漏未来 n_splits: 12 gap: 1 # 训练集末尾与验证集之间空一期,缓冲特征窗口渗透gap这个参数很多人第一眼看不懂。特征里用了 100 期冷号窗口,意味着第 i 行的特征实际“看到”了第 i-100 到 i-1 期。如果训练集截至第 i-1 期、验证集从第 i 期开始,边界上那几行特征和验证标签存在微弱重叠,gap: 1就是给这个渗透留一格缓冲。数值不大,但做时序评估时该留。
min_rows是我加的习惯:样本少于阈值时直接抛异常,而不是训出一个 loss 好看、泛化为零的模型。血泪经验,宁可报错也别出一个假指标。
3. 核心代码逐段讲:从特征矩阵到滚动回测的主链路
数据洗干净只是开始,真正决定这个项目有没有分析价值的是三件事:特征构得对不对、标签定义得清不清、评估协议守不守规矩。下面按调用顺序拆。
3.1 特征构造:遗漏值、冷热号、形态统计
彩票类数据最大的坑是特征看起来很多、信息量却极低。这个包采用了三类相对克制的特征:形态统计(和值、跨度、奇偶比)、逐号遗漏值(某个号码距上次开出过了几期)、窗口热度(最近 N 期出现次数)。三类都不是“预测未来”,而是对历史分布的压缩描述。
import numpy as np import pandas as pd def build_features(df: pd.DataFrame, cfg: dict) -> pd.DataFrame: """输入按时间升序的开奖宽表,输出与 df 行对齐、且只用历史信息的特征表""" reds = df[[f"r{i}" for i in range(1, 7)]].to_numpy() n = len(df) out = df[["issue", "date"]].copy() # 1) 形态类:整注号码的统计摘要 out["sum_val"] = reds.sum(axis=1) out["span"] = reds.max(axis=1) - reds.min(axis=1) out["odd_cnt"] = (reds % 2 == 1).sum(axis=1) out["big_cnt"] = (reds > 16).sum(axis=1) # 大小比按 1-16 / 17-33 切 out["zone1"] = (reds <= 11).sum(axis=1) # 三区分布 out["zone3"] = (reds >= 23).sum(axis=1) # 2) 逐号遗漏值:miss[i][j] 表示第 i 期开奖前,号码 j+1 已经多久没出 miss = np.zeros((n, 33), dtype=np.float32) last_seen = np.full(33, -1, dtype=np.int32) for i in range(n): miss[i] = np.where(last_seen < 0, i + 1, i - last_seen) for num in reds[i]: last_seen[num - 1] = i # 整体下移一行,保证第 i 行看到的是「本期开奖之前」的遗漏 miss = np.vstack([np.full((1, 33), np.nan, dtype=np.float32), miss[:-1]]) miss = pd.DataFrame(miss).ffill().fillna(0).add_prefix("miss_") # 3) 窗口热度:最近 hot_window 期每个号码出现了几次,同样 shift 一位 onehot = np.zeros((n, 33), dtype=np.float32) onehot[np.arange(n)[:, None], reds - 1] = 1 hot = (pd.DataFrame(onehot) .rolling(cfg["features"]["hot_window"], min_periods=1).sum() .shift(1).fillna(0).add_prefix("hot_")) return pd.concat([out.reset_index(drop=True), miss, hot], axis=1)两个shift是整段代码的命门。遗漏值那段的np.vstack把整块矩阵下移一行,热度用.shift(1),效果一样:第 i 行的特征只能包含第 i 期之前的信息,标签是第 i 期的开奖结果。少了这两下,模型会拿到“当期号码出现了几次”这种答案提示,训练 loss 直接掉到 0.1 以下,然后实盘一塌糊涂——这是这个项目最容易翻车的地方,也是我做时序特征时唯一会强制写断言的地方。
参数上,hot_window调小(10~30)抓到的是近期波动,调大(100~300)会趋近全局均匀分布,因为每个号码的长期频率都在 6/33 ≈ 18% 附近。roll_windows里的滚动均值和方差,我一般只保留两个窗口就够,特征维度堆到几百列对这类数据只增加过拟合风险。
顺带说一句,树模型不需要对这几类特征做标准化,遗漏值几十、和值上百,对分裂点的选择没有影响。真要上线性模型或者神经网络,得先做分位数变换,不然量纲会把梯度带偏。
3.2 红球:33 个独立二分类器,而不是 6 个分类头
红球是 33 选 6 且不分顺序。有两种常见建模方式:一是当成 33 类多标签问题,每个号码一个 0/1 标签;二是当成“第 k 个球”的 6 个 33 类分类问题。第二种是典型误用——红球排序之后r1..r6只是大小名次,第 3 个球并不比第 5 个球有更多语义,硬拆成六个头等于给模型灌错误的结构先验。这个包选了第一种。
from lightgbm import LGBMClassifier import numpy as np def train_red_models(X: np.ndarray, Y: np.ndarray, cfg: dict) -> dict: """Y: (n_samples, 33) 的 0/1 矩阵,第 j 列表示号码 j+1 是否开出""" params = dict(cfg["model"]["params"]) models = {} for j in range(33): y = Y[:, j] pos_rate = y.mean() # 正样本太少时退化为常数先验,训出来的树没有统计意义 if y.sum() < 30: models[j] = ("prior", float(pos_rate)) continue clf = LGBMClassifier(objective="binary", random_state=42, **params) clf.fit(X, y) models[j] = ("lgbm", clf) return models def predict_red_proba(models: dict, X: np.ndarray) -> np.ndarray: """输出 (n_samples, 33) 的边际概率矩阵,注意它不是一注号码""" proba = np.zeros((len(X), 33), dtype=np.float32) for j, (kind, obj) in models.items(): if kind == "prior": proba[:, j] = obj else: proba[:, j] = obj.predict_proba(X)[:, 1] # 每期强制归一化到 6 个球,保持与真实分布同量级,方便回测对比 proba *= 6.0 / proba.sum(axis=1, keepdims=True) return proba每个号码的正例率大约是 6/33 ≈ 18.2%,不算极端不平衡,所以没上scale_pos_weight,直接binary就行。33 个模型串行训练,600 棵树 × 33,单机跑完几分钟,成本可以接受;想快就把n_estimators降到 200 先看趋势。
predict_red_proba末尾那行归一化很有必要,但容易被误解:它只是把 33 个边际概率缩放到总和为 6,量级上对得上“一期出 6 个球”,不代表这 33 个数能直接拼成一注有效组合。真正取一注还得做无放回抽样或者按约束求解,这部分放到第 6 章讲。很多人看到概率矩阵就直接argsort取前 6,结果每期输出一模一样,以为是模型没训好,其实是取法错了。
3.3 蓝球:二分类加概率校准
蓝球是 16 选 1,单标签多分类,用predict_proba拿 16 个类别的概率最直接。但要注意,项目里默认走的是“16 个独立二分类”还是“单模型 16 分类”,取决于数据量:样本上万时直接multiclass更稳,几千条时独立二分类反而收敛得快。无论哪种,概率都得校准,否则输出的 0.4 和真实频率 0.4 对不上,后面的组合抽样就是自欺欺人。
from sklearn.calibration import CalibratedClassifierCV from sklearn.metrics import brier_score_loss, log_loss import numpy as np def train_blue(X_tr, y_tr, X_va, y_va, base_clf): """isotonic 校准需要足够样本,样本少时用 sigmoid""" method = "isotonic" if len(X_tr) >= 3000 else "sigmoid" clf = CalibratedClassifierCV(base_clf, method=method, cv=3) clf.fit(X_tr, y_tr) p = clf.predict_proba(X_va)[:, 1] base = np.full_like(p, y_tr.mean()) # 全猜训练集先验的基线 print(f"logloss model={log_loss(y_va, p):.4f} " f"base={log_loss(y_va, base):.4f}") print(f"brier model={brier_score_loss(y_va, p):.4f} " f"base={brier_score_loss(y_va, base):.4f}") return clf基线一定要打出来。16 类均匀分布的对数损失是 ln16 ≈ 2.77,全猜某个固定号码的基线大约也是这个量级。如果模型 logloss 是 2.75,看着“比基线好”,实际差距在噪声范围内,没有任何实用意义。我见过太多人只贴一个accuracy: 0.07就宣布模型有效,而 1/16 = 6.25%,7% 和 6.25% 的差别,在几千条验证样本上根本不是显著差异。
Brier 分数比准确率更能反映概率质量,值越小越好,均匀猜的 Brier 约 0.0586(对二分类而言),这两个数放在一起看,就能判断模型到底学到了东西还是只学到了先验。
3.4 回测:滚动窗口和必须对齐的基线
回测协议是整个项目里最不能省的部分。随机切分(train_test_split(shuffle=True))在这类数据上会把未来样本混进训练集,指标虚高得离谱。这个包只支持滚动窗口,每一折训练集都在验证集之前。
import pandas as pd import numpy as np def rolling_backtest(df: pd.DataFrame, cfg: dict, fit_predict) -> pd.DataFrame: """fit_predict(train_df, test_df) 内部自行构造特征,外部不传任何全量统计量""" n_splits = cfg["model"]["cv"]["n_splits"] gap = cfg["model"]["cv"]["gap"] step = len(df) // (n_splits + 1) rows = [] for k in range(n_splits): train_end = step * (k + 1) test_start = train_end + gap test_end = min(test_start + step, len(df)) if test_start >= len(df): break tr = df.iloc[:train_end] te = df.iloc[test_start:test_end] preds = fit_predict(tr, te) # 返回每期的概率矩阵与实际号码 rows.append(evaluate(preds, te)) return pd.DataFrame(rows).agg(["mean", "std"]).T def evaluate(preds: dict, te: pd.DataFrame) -> dict: """核心指标:红球平均命中个数,与超几何分布期望 6*6/33≈1.09 对比""" hits = [] for proba, actual in zip(preds["red_proba"], te[[f"r{i}" for i in range(1, 7)]].to_numpy()): top6 = np.argsort(-proba)[:6] + 1 hits.append(len(set(top6) & set(actual))) return {"red_hit_mean": float(np.mean(hits)), "n": len(hits)}evaluate里的 1.09 这个基线值得记住:随机选 6 个红球,命中个数的数学期望是 6 × 6/33 ≈ 1.09。模型如果做到 1.15,只能说明它在“选热门号”这件小事上略微有效——因为热门号的出现频率确实长期稳定在 18% 附近,而真实分布并不均匀。做到 1.3 以上才值得怀疑是不是泄漏了,而不是庆祝。
fit_predict的签名设计是刻意为之:训练集和测试集分开传进去,特征构造在函数内部对tr单独做。这样任何“用全量数据算均值再 transform”的写法都会在代码审查时暴露出来。做时序回测,接口设计比纪律更可靠。
4. 把项目跑起来:三条命令和四个输出目录
结构看明白了,接下来是实打实的环境。这类项目依赖不重,主要是 pandas、numpy、scikit-learn、lightgbm,加个 pyarrow 读 parquet。真正卡人的往往不是装包,而是数据文件和路径。
4.1 环境安装与数据准备
建议单独建虚拟环境,别往系统 Python 里灌。LightGBM 在部分平台上需要编译工具链,用 wheel 装最省事。
# 1) 建环境(Python 3.9+ 均可) python -m venv .venv source .venv/bin/activate # Windows 用 .venv\Scripts\activate # 2) 装依赖,版本锁在 requirements.txt 里 pip install -r requirements.txt # 3) 准备原始数据,放到 configs/default.yaml 里配置的路径 mkdir -p data/raw # 把整理好的开奖记录存成 data/raw/draws.csv,至少要 issue,date,red,blue 四列 # 4) 先跑最小配置验证链路,几百条数据也能走通 python -m src.data.build_dataset --config configs/fast.yaml原始 csv 至少要有issue、date、red、blue四列,红球用空格或逗号分隔都行,解析函数两种都兼容。如果你的数据里日期格式不统一,先在parse_draw里补format参数,别指望to_datetime自动猜对所有情况——errors="coerce"会把解析失败的行静默变成 NaT,然后被dropna悄悄丢掉,数据少了一截你还不一定发现。
fast.yaml存在的意义就是先跑通再上量。我第一次解这类包时习惯直接上全量,结果卡在一个字段类型上折腾半小时,用fast.yaml二十秒就能定位是环境问题还是数据问题。
4.2 三个命令行入口
主链路就三条命令,分别对应构建、训练、预测,回测是训练之后的可选步骤。
# 构建特征矩阵,输出到 data/processed/ python -m src.data.build_dataset --config configs/default.yaml # 训练红球 33 个二分类器 + 蓝球模型,模型存到 outputs/models/ python -m src.train --config configs/default.yaml --model lightgbm # 滚动回测,折数可在命令行覆盖 yaml 里的配置 python -m src.backtest --config configs/default.yaml --folds 12 # 对下一期做推理,输出概率矩阵和候选组合 python -m src.predict --config configs/default.yaml --topk 12--model参数支持lightgbm、xgboost、logistic三个值,最后一个用来跑基线,很方便。--folds覆盖配置而不是改文件,是我比较推荐的做法:实验参数走命令行,工程参数留在 yaml,两者职责别混。
--topk 12的意思是输出概率最高的 12 个红球候选,从里面组合出若干注。这个数别调太大,超过 18 之后候选集几乎覆盖全部号码,输出就没信息量了。
4.3 输出物与日志排查
跑完之后outputs/下会出现这些东西,每个都有明确用途:
| 路径 | 内容 | 什么时候看 |
|---|---|---|
outputs/models/red/*.txt | 33 个 LightGBM 模型文件 | 换机器复现时确认版本一致 |
outputs/models/blue.pkl | 校准后的蓝球模型 | 推理前必须存在 |
outputs/metrics/backtest.json | 每折 logloss、Brier、平均命中数 | 判断模型有没有学到东西 |
outputs/metrics/feature_importance.csv | 33 个模型的平均增益 | 砍冗余特征 |
outputs/predict/next.csv | 下一期概率矩阵 + 候选组合 | 日常输出的结果 |
日志级别用--log-level DEBUG打开会打印每一折的训练区间和验证区间,排查泄漏时非常有用。feature_importance.csv我一般看前 20 行,如果排在最前面的全是hot_*和miss_*,说明模型主要在学号码频率;如果形态特征(和值、跨度)排得很高,反而要警惕,这类特征和具体号码没有因果关系,多半是在拟合噪声。
5. 踩坑排查:这套代码在我机器上翻过的五次车
跑通只是开始,能不能得到有意义的结论是另一回事。下面五条都是我实际遇到过的问题,按现象、原因、解决记下来,照方抓药能省不少时间。
5.1 数据与环境:两个看起来像 bug 的配置问题
现象一:换台机器跑,训练明明成功了,预测时报KeyError: 'miss_1'。原因:特征矩阵用 parquet 落盘,不同 pandas 版本对列顺序和索引的恢复行为不一致,加上部分环境没装 pyarrow,静默退回了 csv,索引列被当成了普通列。 解决:在build_dataset末尾显式df = df[sorted(df.columns)]固定列序,读文件时显式指定encoding="utf-8-sig",并把 pyarrow 写进 requirements。自从踩过这次坑之后,凡是落盘的特征表我都固定列序再存。
现象二:build_dataset跑完没报错,但样本数比原始文件少了两千多条。原因:pd.to_datetime(..., errors="coerce")把格式异常的日期变成 NaT,紧接着的dropna把它们全删了,日志里没有任何提示。 解决:解析后先打印raw_len和clean_len的差值,超过 1% 就raise。数据静默丢失是最难查的一类问题,宁可中断也别默默少一截。
5.2 模型层:标签泄漏和错误的取法
现象三:训练集 AUC 0.78,验证集 AUC 0.51,一路横着走。原因:特征里混进了当期开奖的统计量。最隐蔽的一处是遗漏值,代码里遍历reds[i]更新last_seen之后没有把结果整体下移,第 i 行拿到的是“本期开完之后”的遗漏。 解决:在特征构造函数末尾加断言,检查任意一行的特征是否与当期号码相关,最直接的办法是构造一个仅用df.iloc[:i]重算的参照值做比对。这条断言我后来写成了模板,所有时序特征一律带。
现象四:每期预测出来都是同一组 6 个红球,连续跑十期一模一样。原因:predict_red_proba输出的边际概率矩阵直接argsort取前 6,等于每期都取边际概率最高的那批号码,而边际概率本身随时间变化很慢。 解决:改成按概率无放回抽样,或者加上和值、奇偶、区间分布的约束后求解,让输出体现模型的不确定性。这件事说明概率矩阵和“一注号码”之间还差一层采样逻辑,别混为一谈。
5.3 回测与结果解读:别拿训练集成绩当结论
现象五:回测平均命中 2.4 个红球,激动了半小时,换时间段复现掉到 1.1。原因:features里的滚动均值和冷热窗口在切分之前对全量数据算好了,等于用未来数据构造了历史特征;另外回测只有 3 折,样本量太小,2.4 这个数字本身就是噪声。 解决:所有窗口统计量只在训练窗口内fit,验证窗口只做transform;折数提到 12 折以上;每次必须打印超几何基线 1.09 做对比。做完这三步,命中数回到 1.1 附近,才是真实水平。
另外提醒一句,蓝球的accuracy 0.07这类指标单独看没有意义,1/16 = 6.25%,样本量少的时候 7% 完全在随机波动范围内。把 logloss 和 Brier 一起打出来,再跟均匀分布基线并排放,结论才站得住。
6. 二次开发:把它改成正经的时间序列实验框架
这个包底子不错,但要真拿来做实验,有几处必须改。第一处是把 33 个独立二分类换成多标签模型(MultiOutputClassifier或者 LightGBM 的multiclass直接对 33 个位置做联合建模),边际概率之间会互相约束,输出更合理。第二处是加一层组合采样,把概率矩阵变成可解释的候选注数。
import numpy as np def sample_ticket(proba: np.ndarray, k: int = 6, rng=None) -> list[int]: """按边际概率无放回抽样生成一注,比 argsort 取 top-k 更能反映不确定性""" p = np.asarray(proba, dtype=np.float64) p = np.clip(p, 1e-6, None) p = p / p.sum() idx = rng.choice(np.arange(1, 34), size=k, replace=False, p=p) return sorted(idx.tolist()) def sample_tickets(proba, n_tickets=5, k=6, seed=7): rng = np.random.default_rng(seed) seen, out = set(), [] while len(out) < n_tickets: t = tuple(sample_ticket(proba, k, rng)) if t not in seen: # 去重,避免输出重复注 seen.add(t) out.append(list(t)) return outsample_ticket的关键在replace=False:无放回抽样天然保证一注里号码不重复,比先取 33 个概率再过滤重复组合要干净得多。clip防止某些号码概率被校准压到 0 之后抽样报错。seed固定是为了让同一次实验可复现,出报告时能对得上。
改造清单按优先级排一下:
| 改造点 | 现状 | 建议做法 |
|---|---|---|
| 模型结构 | 33 个独立二分类 | 换多标签联合建模,或加号码间相关性特征 |
| 采样层 | argsort取 top-k | 无放回抽样 + 约束过滤(和值区间、奇偶配比) |
| 评估 | 单点命中数 | 加概率校准曲线、分位数覆盖率 |
| 特征 | 全是号码频率类 | 引入外部变量(销售数据、时间间隔)做对照实验 |
| 实验管理 | 手动改 yaml | 接 mlflow 或简单的 run_id 目录,每次实验独立落盘 |
我最想强调的还是评估协议。改完模型先别急着看命中数,把校准曲线画出来,横轴预测概率、纵轴实际频率,理想情况是一条 45 度直线。如果模型输出的 0.3 对应实际 0.18,那后面的组合采样全在放大偏差。这套流程我从一个翻了车的项目里学来的:当时模型指标好看,上线后全线崩,查了两天才发现是校准没做,概率全挤在 0.5 附近。从那以后我每次做概率输出,都强制先过一遍校准曲线和 Brier 分数,再谈业务指标。希望帮到你。
本文还有配套的精品资源,点击获取