887定位大底=23:组合过滤、打分排序与回测实践
1. 把“887定位大底=23策略”先翻译成人话
先说清楚一件事,免得后面越看越误会:三位数彩票(通常说的3D玩法)每一期开出的三个数字,是相互独立的随机事件,单注直选的理论命中概率就是千分之一。任何“定位”“杀号”“缩水”操作,本质都是组合空间的裁剪,它改变的是你买了几注、覆盖了多少种可能,并不会改变任意一注被开出的概率。这一点如果不先摆正,后面所有的脚本、参数、回测都没有意义。我写这篇东西,讲的是怎么用代码把一整套组合生成、条件过滤、打分排序、历史回测的流程搭起来,把它当成一个数据处理与统计验证的练手项目来看待,而不是当成什么预测工具。心态先放平,量力而行,把它当娱乐和编程练习,别当收入来源。
这次的标题里塞了一堆行话:887定位大底、=23、杀断组、杀组选、杀和尾、杀和值012、缩水、测试。外行看着像天书,其实拆开就四件事:第一步划定候选池,第二步算清楚候选池有多少注,第三步用一串布尔条件往下砍,第四步砍不到目标注数就靠排序截断补刀。标题里那个“5月16日”“第2弹”只是测试记录的归档标签,换成任何一期都一样跑,方法本身跟日期无关。整套流程的核心关键词就三个:定位大底、注数预算、条件留存率。抓住这三个词,这篇内容你就能看懂个七七八八。
适合谁看?写过一点Python、对itertools不陌生、想练一练“组合生成+条件过滤+回测统计”这条链路的人最合适。哪怕你完全不碰这类数字游戏,这套“先定预算、再按杀伤力分配过滤条件、最后做留存率归因”的思路,在做AB测试人群圈选、风控规则链设计、库存SKU筛选的时候是通用的。我下面会把每个参数的来历、每一步为什么要这么写、以及实测里最容易被忽略的几个坑,一条条摊开讲。
1.1 定位法到底在干什么
所谓“定位”,就是放弃“三个数字一起猜”的思路,改成按位置分别给候选数字。百位一个候选集合,十位一个候选集合,个位一个候选集合,三个集合做笛卡尔积,得到的就是“定位大底”。这套思路流行起来的原因很朴素:直接在全量1000注里瞎选,人的直觉完全用不上;而拆成三个位置以后,你可以对每个位置单独做冷热统计、遗漏统计、奇偶统计,主观上感觉“可控”了很多。
但这里有个必须说透的点:按位置拆开,只是让筛选过程变得可描述、可复现,并不代表每个位置真的存在可利用的规律。我做过很多次位置级的频率统计,长期看每个数字在每个位置上的出现次数都收敛到接近均匀,短期的偏离属于正常波动范围。所以定位法的真正价值在于“把模糊的手感变成明确的参数”,而不是“发现了规律”。你在脚本里写bai = {0,1,2,3,4,5,6,7},这个集合是怎么来的必须能说清楚——是近N期出现频次排序取前8,还是遗漏值排序,还是纯粹拍脑袋。这三种来源对应的可信度是完全不同的,后面回测的时候要分开看。
我自己的习惯是:定位集合用固定规则生成,不用手工挑。比如“取近30期各位置出现次数排名前8的数字”,规则写死在代码里,这样每一期都能自动复现,也不会出现“这期我觉得7该出了”这种事后合理化。规则固定下来以后,回测才有意义;规则天天变,回测就是在给自己讲故事。
1.2 887这三个数字背后的注数账
“887”不是三个随机数字,它是百位留8个、十位留8个、个位留7个的缩写。注数很好算,直接乘:8 × 8 × 7 = 448注。这一步是整条链路里唯一能精确计算的环节,也是最容易被忽略的环节——很多人报大底的时候只报“887”,不报注数,实际上448注和另一个“887”可能因为集合重叠程度不同,内部结构完全不一样。
举个具体的例子帮你理解“结构不一样”是什么意思。假设百位留{0,1,2,3,4,5,6,7},十位留{0,1,2,3,4,5,6,7},个位留{0,1,2,3,4,5,6}。这三个集合高度重叠,那么448注里“豹子”(三个数字全同)有7注,“组三”(两个数字相同)有147注,“组六”(三个数字互不相同)有294注。7 + 147 + 294 = 448,账能对上。但如果个位留的是{3,4,5,6,7,8,9},跟百位十位只重叠一半,那组三的数量会明显变少,组六比例会升高。同样是“887”,内部形态分布差得远。
这个细节为什么重要?因为后面“杀组选”这一刀砍的就是形态。如果你的大底本身组三就很少,那“杀组三”这一刀的杀伤力就很小,留存率会虚高,你会误以为这个条件很“温和”,实际上它只是没东西可砍。统计任何一个过滤条件的杀伤力,必须放在具体的大底上算,不能拿全集1000注的比例直接套。这是我在最初几轮测试里踩得最狠的坑,后面第5节还会展开说。
1.3 “=23”不是玄学,是一条注数预算
标题里那个“=23”,不同圈子的解读五花八门,我按最常见也最工程化的一种理解来处理:把缩水后的目标注数锚定在23注上下。这个理解的好处是它能反向约束前面所有条件的力度——23注除以1000注是2.3%的覆盖率,也就是说你要从448注砍到23注,得砍掉大约94.9%的组合。
我把这个叫“注数预算”。做预算思维和拍脑袋选号的区别在于:预算是硬约束,你砍到23注就停手,不许因为“这几个号看着顺眼”偷偷加进去;预算也是软约束,如果砍完只剩11注,说明条件太狠了,得回退某一刀。有了预算,整个流程就变成了一个可调、可验证的系统工程,而不是每期重新凭感觉来一遍。
提示:预算定得越低,单注覆盖越集中,理论上“命中时的注数效率”越高,但同时“一期都不中”的连续空窗也会拉长。23注这个量级,按2.3%的单期覆盖率计算,连续10期不中的概率大约是(1-0.023)^10 ≈ 79%,也就是大概率十来期才见一次。这个数字必须先算出来给自己打个底,否则很容易在连续空窗期里心态失衡、开始乱加条件。
2. 五道杀号条件逐个拆:每一刀砍的是什么形态
标题里列了五个“杀”:杀断组、杀组选、杀和尾、杀和值012,还有一个隐含的“杀”体现在定位大底本身。这几个条件的排列顺序不是随便写的,它直接决定了每一刀的实际杀伤力。因为过滤是布尔运算,先砍哪一刀,后面每一刀的“可砍对象”就少一批,砍完再算比例,跟单独算比例完全是两回事。我把这五刀按“从粗到细”的顺序重新排了一遍,理由在后面逐条说明。
2.1 杀组选:先砍形态,再谈数字
“组选”说的是形态分类,不是具体数字。三个数字全不一样叫组六,有两个一样叫组三,三个全一样叫豹子。“杀组选”在实操里通常有两种做法:一种是只保留组六,把组三和豹子全砍掉;另一种是保留组六和组三,只砍豹子。我这次测试用的是第一种,理由是豹子只有10注、组三有270注(按全集直选口径算),这两个形态合起来占了28%的空间,砍掉以后剩下的组合形态单一,后面做打分排序时权重好统一。
注意:豹子虽然只有10注,但它是“冷门里的冷门”,砍掉它在绝大多数期数里没有任何感觉,可一旦真开出来,你的23注必然全灭。这就是取舍——用极小概率的确定性损失,换取更大空间里的筛选自由度。我自己是接受这个取舍的,但你必须知道自己在放弃什么,不能砍完了还以为自己“覆盖了全部可能”。
在448注这个具体大底上,只保留组六会从448注降到294注,砍掉34.4%。注意这个34.4%跟全集口径下的28%不一样,原因就是1.2节讲的结构差异。这个差距本身就是一个信号:你的大底已经偏向了某种形态,如果偏向组六,说明三个位置的候选集合重叠度高;如果偏向组三,说明集合之间错位大。这个信息可以用来反过来检查你的定位集合是不是选得太集中了。
代码上这一刀非常好写,一行就够:
def keep_group6(nums): """三数互不相同 -> 保留""" return len(set(nums)) == 3看起来很朴素,但这里有个细节值得说:len(set(nums)) == 3同时排除了组三和豹子,如果你想分开控制,就得写len(set(nums)) == 2(组三)和len(set(nums)) == 1(豹子)两个判断。分开写的好处是回测时能分别统计“如果只砍豹子”和“如果连组三一起砍”的差别,方便做敏感性分析。任何一刀都应该做成可开关的,这是回测的基本要求,写死了就没法做对照实验。
2.2 杀断组:用分组覆盖率过滤极端形态
“断组”这个词听着玄,实际逻辑很直白:把0到9这十个数字预先划成几个小组,比如{0,1,2,3}、{4,5,6}、{7,8,9}这样三段。开奖的三个数字如果全部落在同一个小组里,或者三个数字横跨了全部三个小组(每个组各出一个),这两种形态都叫“断组”或者“穿组”,具体叫法各地不一。我这里的处理是砍掉“三个数字全部落在同一小组”的情况,也就是覆盖度只有1的形态。
为什么砍这个?因为三个数字全挤在一小段区间里,属于分布上的极端尾部。以{0,1,2,3}为例,三个数字都在这个区间内的组合有4×4×4=64种,占全集6.4%,概率确实不高。砍掉它,等于主动放弃了这6.4%的空间,换取剩下组合的“形态更居中”。
实现上不要写死分组,把分组做成参数:
GROUPS = [{0,1,2,3}, {4,5,6}, {7,8,9}] def not_all_in_one_group(nums, groups=GROUPS): hit = set() for n in nums: for i, g in enumerate(groups): if n in g: hit.add(i) break return len(hit) >= 2 # 至少覆盖两个组才保留这里len(hit) >= 2意味着覆盖度至少为2。如果你想更狠,可以改成>= 3,那就是要求三个数字分别落在三个小组里,杀伤力会大得多——全集口径下这种形态大约占三成多,一刀砍掉七成。但我不建议一上来就开这么狠,因为覆盖度要求越高,剩下的组合就越“规整”,而规整本身就是一种人为偏好,没有任何统计依据支撑它更容易出现。
实操心得:断组的分组方式对这个条件的杀伤力影响巨大。三段均分(4-3-3)和三段不均分(比如5-3-2),在同一个大底上砍掉的注数能差一倍。我建议分组固定用均分,别去调,调了就变成过拟合的入口。
2.3 杀和尾与杀和值012:两条最容易被重复计功的规则
“和值”是三个数字相加的和,范围0到27。“和尾”就是和值的个位数。“杀和值012”这个名字有歧义,两种理解都有人用:一种是指和值尾数落在{0,1,2}就砍,另一种是指和值本身在0到12这个低段就砍。这两种理解差别很大,而且在很多人的策略里,这两条规则其实是同一个东西的两种叫法,写在一起就变成了重复计功——你以为砍了两刀,实际只砍了一刀。
我这次测试采用的是不重叠的版本:杀和尾指砍掉指定的两到三个和值尾数(比如尾0、尾5、尾9),杀和值012指砍掉和值0到12这个低段区间。这样两条规则的覆盖范围基本不交叉,各自统计留存率才有意义。
先说杀和值0到12这一刀的实际力度。全集1000注里,和值分布是个对称的钟形,0到12这一段的注数加起来是425注,占42.5%。注意这个数字:和值小于等于12的概率接近一半,所以这刀砍下去是实打实的大砍,不是修修补补。很多人以为低和值是“小概率冷门”,这是错觉——和值3只有10注,可和值10有63注,加起来一大片。这就是为什么我在开头强调“参数要有据可循”,凭感觉砍和值区间,很容易一刀砍掉四成还浑然不觉。
杀和尾这一刀的力度取决于你砍几个尾。全集上每个和值尾的占比不完全均匀(因为和值0到27的分布不是平的),大致每个尾在9%到11%之间浮动。砍三个尾就是砍掉30%左右。但这里有个坑:如果你已经砍掉了和值0到12,那么低段的尾数其实已经被砍过一遍了,再按尾数砍的时候,实际砍掉的组合数会比30%少,因为有一部分已经被前面的条件清空了。这就是“条件重叠”的典型表现,也是留存率归因必须按顺序做、不能单独做的原因。
两条规则写成代码都很短:
def kill_sum_low(nums, low=12): return sum(nums) > low def kill_sum_tail(nums, banned_tails=(0, 5, 9)): return sum(nums) % 10 not in banned_tails3. 把流水线用脚本跑通:生成、过滤、排序、截断
到这一步,五个条件都有了,理论上挨个过滤就行。但真正跑起来你会发现一个问题:所有条件全开,448注大概会剩五六十注,离23注的目标还差一截。这不是参数没调好,而是布尔过滤这种机制的固有限制——它是“通过/不通过”的二值判断,没办法表达“优先保留哪些”的偏好。想压到23注,必须引入排序。这一节就把完整链路跑一遍,从生成到截断。
3.1 第一步:笛卡尔积大底的生成与内存控制
生成大底最简单的写法是itertools.product,三行就完事:
from itertools import product bai = list(range(0, 8)) # 8个 shi = list(range(0, 8)) # 8个 ge = list(range(0, 7)) # 7个 pool = [(b, s, g) for b, s, g in product(bai, shi, ge)] print(len(pool)) # 448448注这个量级,内存完全不是问题,随便怎么存都行。但如果你以后想把大底扩大到“999”或者叠加更多维度,就得注意了:product返回的是迭代器,[(...)]列表推导会一次性全展开,几十万注也扛得住,但如果你在循环里反复重新生成,性能会明显掉下来。我的做法是生成一次,存到一个列表里,后续所有过滤都在这个列表上做,不做重复生成。
数据结构上我推荐直接用三元组(b, s, g),不要存成字符串。看起来字符串更直观,但每次过滤都要int(x[0])转来转去,代码会变脏,而且容易出类型错误。用三元组,nums[0]就是百位,干净利落。输出展示的时候再拼成字符串也不迟。
还有一个容易被忽略的点:大底必须带期号和参数快照。我早期跑测试的时候,同一份脚本跑了七八期,结果发现某一期的结果怎么都对不上——原因是中间改过定位集合的生成规则,但没记录。后来我给每个结果对象都挂了一个meta字段,把当期使用的定位规则、分组方式、过滤条件、版本号全部塞进去。这样任何时候翻出来都能复现,回测的结论才有可信度。
meta = { "issue": "某期", "bai_rule": "recent30_top8", "shi_rule": "recent30_top8", "ge_rule": "recent30_top7", "groups": [[0,1,2,3],[4,5,6],[7,8,9]], "filters": ["group6", "sum_low", "sum_tail", "duan_group"], "topn": 23, "version": "v3", }3.2 第二步:可插拔过滤器链的设计
过滤器一定要写成链式,不要写成一坨if嵌套。原因有两个:一是方便单独统计每一刀的留存率,二是方便开关某一刀做对照实验。我用的结构是一个函数列表,每个函数接收号码元组、返回布尔值,主流程顺序遍历:
filters = [ ("keep_group6", lambda n: keep_group6(n)), ("kill_sum_low", lambda n: kill_sum_low(n, 12)), ("kill_sum_tail", lambda n: kill_sum_tail(n, (0, 5, 9))), ("not_duan", lambda n: not_all_in_one_group(n)), ] def apply_filters(pool, filters): trace = [] current = list(pool) for name, fn in filters: before = len(current) current = [n for n in current if fn(n)] after = len(current) trace.append({ "filter": name, "before": before, "after": after, "kept_rate": round(after / before, 4) if before else 0, "killed": before - after, }) return current, trace这个trace就是留存率归因的核心。每一刀砍了多少、留存多少,一目了然。我把它打成一个表格形式的输出,每次跑完先看这张表,比看最终结果有用得多。
这里要强调顺序问题。我测试下来,不同顺序跑出来的最终注数完全一样(布尔运算满足交换律),但每一刀的留存率会变。所以做归因分析时,顺序必须固定下来并写进meta,否则两期结果没法横向对比。我固定的顺序是:先砍形态(组选),再砍区间(和值低段),再砍尾数,最后砍分组覆盖。理由是从粗略到精细,前面的刀力度大、解释性强,后面的刀做微调。
3.3 第三步:条件砍不到23注,得靠打分排序
跑完上面的链子,实际留存通常落在50到90注这个区间,具体数字取决于当期定位集合的分布。离23注还差两三倍。这时候硬加条件是不行的——再加一刀很可能直接砍到个位数甚至砍空,而且新加的条件大概率是拍脑袋来的,没有依据。
正确的补刀方式是打分排序取前23。打分的维度我用了四个,每个维度给一个权重,加权求和后降序排列:
| 维度 | 说明 | 建议权重 | 打分方式 |
|---|---|---|---|
| 和值中枢度 | 和值越接近历史高频段得分越高 | 0.35 | 按abs(sum - 13.5)反向线性映射到0到1 |
| 跨度适中度 | 跨度(最大减最小)在4到7之间得分高 | 0.25 | 落在区间内记1,区间外按距离衰减 |
| 分布均衡度 | 三个数字覆盖的分组数越多越好 | 0.20 | 覆盖度2记0.5,3记1.0 |
| 尾数稀缺度 | 和值尾在近期出现少的略加权 | 0.20 | 按近N期尾数频次反向归一化 |
def score(nums, tail_freq): b, s, g = nums total = sum(nums) span = max(nums) - min(nums) s1 = max(0.0, 1 - abs(total - 13.5) / 13.5) if 4 <= span <= 7: s2 = 1.0 else: s2 = max(0.0, 1 - (min(abs(span - 4), abs(span - 7)) / 5)) cover = len({i for n in nums for i, grp in enumerate(GROUPS) if n in grp}) s3 = 0.5 if cover == 2 else (1.0 if cover == 3 else 0.0) tf = tail_freq.get(total % 10, 0) s4 = 1.0 / (1 + tf) return 0.35 * s1 + 0.25 * s2 + 0.20 * s3 + 0.20 * s4权重的来历必须说清楚:这四个权重没有任何统计最优性,纯粹是我拍的主观偏好。和值中枢度给最高,是因为它对应的中间段组合数量最多、分布最集中;跨度给次高,是因为极端跨度(0到2或者8到9)的形态太少见;后两个是辅助微调。你在自己的项目里完全可以换一套权重,但换完必须做回测对比,不能凭感觉说“我这个更好”。
提示:权重之和必须归一化到1。我见过有人权重加起来是1.5,排序结果被放大了1.5倍但相对顺序没变,看起来“没影响”,实际上如果后续要做阈值截断(比如“只保留分数大于0.6的”),整个阈值就废了。归一化是个小习惯,能省很多排查时间。
排序截断的代码就三行:
scored = [(n, score(n, tail_freq)) for n in current] scored.sort(key=lambda x: x[1], reverse=True) final23 = [n for n, _ in scored[:23]]截断到23注之后,还有一个必须检查的项:这23注的形态分布是否过于集中。比如排序偏重和值中枢度,很可能最后选出来的23注全挤在和值11到16这一段。这本身不是错误,但你要知道自己在承担什么——一旦开出的和值偏离中枢,这23注就是全灭。我会在输出里附一个形态摘要(和值分布、跨度分布、组六占比),放在那里自己看,不作为调整依据。
3.4 第四步:留存率与注数预算的实时对照
整条链子跑完,我习惯打印一张“预算消耗表”,把每一阶段还剩多少注、相对1000注全集的覆盖率是多少、砍掉了多少都列出来:
| 阶段 | 剩余注数 | 相对全集覆盖率 | 本阶段砍掉 |
|---|---|---|---|
| 887定位大底 | 448 | 44.8% | 552 |
| 杀组选(只留组六) | 294 | 29.4% | 154 |
| 杀和值0到12 | 约160 | 约16% | 约134 |
| 杀和尾0/5/9 | 约110 | 约11% | 约50 |
| 杀断组(覆盖度小于2) | 约66 | 约6.6% | 约44 |
| 打分排序取前23 | 23 | 2.3% | 约43 |
这张表里的数字是某一期的实跑结果(约数),不是固定值,每期都会浮动。它的价值不在于数字本身,而在于让你看清每一刀的实际贡献。我第一版的时候,主观以为“杀和尾”是主力,跑完发现它的实际贡献排第三,真正的第一刀是定位大底本身。看清这一点,后面的优化方向就明确了:与其在过滤条件上抠细节,不如回头审视定位集合的生成规则合不合理。
4. 回测要怎么做才算数
前面三节讲的都是“怎么把流程跑通”,这一节讲“跑通了以后怎么判断它有没有意义”。说实话,这是整个项目里最容易自欺欺人的环节。我见过太多人拿几十期数据跑一遍,看到23注里中了两三次就宣布策略有效,完全不管随机选23注在同样期数里也大概率能中个一两次。
4.1 回测口径的三个坑
第一个坑是期数太少。23注的覆盖率是2.3%,理论上平均43.5期才中一次。你拿50期数据跑,中0次、1次、2次都在正常波动范围内,根本区分不出策略好坏。想要有统计意义,至少得几百期起步,而且这几百期里你的规则不能中途改。
第二个坑是规则用了未来数据。定位集合如果用“近30期出现频次前8”来生成,那么回测第T期的时候,只能用T期之前的数据算频次,不能用T期之后或者包含T期的数据。这个细节很多人写代码时会顺手写错——比如先把整个历史数据算一遍全局频次,然后拿这个全局频次套到每一期上。这样跑出来的结果一定是虚高的,因为全局频次里已经包含了未来的信息。正确做法是滚动窗口,每一期都重新算一遍。
第三个坑是只统计命中,不统计空窗。有人统计“100期里中了5次”,听着不错,但没说这5次集中在哪。如果5次里有4次挤在相邻十期,那策略在其余90期里就是持续空窗。我在回测输出里会额外算一个指标:最长连续不中期数,用它来衡量实际承受的心理压力。
4.2 23注策略与随机23注的对照实验
这是我每次做完任何策略都要跑的对照:在同一段时间里,随机抽23注(不经过任何筛选),跟你的筛选结果比命中次数。如果两者没有明显差距,那你的筛选就是在做无用功,或者说,它筛掉的只是一部分空间,剩下的空间分布跟随机没啥区别。
回测框架大致是这样:
import random def backtest(history, builder, filters, topn=23, seed=42): """history: [(期号, (b,s,g)), ...] 按时间升序""" rng = random.Random(seed) strategy_hits = 0 random_hits = 0 max_gap_s, max_gap_r = 0, 0 cur_s, cur_r = 0, 0 for i in range(len(history)): if i < 30: continue past = [v for _, v in history[:i]] # 只用过去数据 issue, actual = history[i] pool = builder(past) # 定位大底 current, _ = apply_filters(pool, filters) scored = sorted(((n, score(n, {})) for n in current), key=lambda x: x[1], reverse=True) final = [n for n, _ in scored[:topn]] if actual in final: strategy_hits += 1 cur_s = 0 else: cur_s += 1 max_gap_s = max(max_gap_s, cur_s) rand_pool = rng.sample( [(b, s, g) for b in range(10) for s in range(10) for g in range(10)], topn ) if actual in rand_pool: random_hits += 1 cur_r = 0 else: cur_r += 1 max_gap_r = max(max_gap_r, cur_r) n = len(history) - 30 return { "期数": n, "策略命中": strategy_hits, "策略命中率": round(strategy_hits / n, 4), "随机命中": random_hits, "随机命中率": round(random_hits / n, 4), "理论值": topn / 1000, "策略最长空窗": max_gap_s, "随机最长空窗": max_gap_r, }跑出来的结果大概率是这样的:策略命中率在2%上下浮动,随机命中率也在2%上下浮动,两者差距在几十期的样本里完全被噪声淹没。这个结果一点都不意外,因为它就是概率论该给出的答案。如果哪一天你跑出来策略命中率显著高于2.3%且持续几百期稳定,那大概率是你代码写错了(用了未来数据),而不是你发现了规律。这句话我建议每个做这类项目的人都记下来。
4.3 结果怎么读:显著性还是幸存者偏差
做完对照,还有一个更隐蔽的问题:你做了多少次尝试。如果你调了二十组参数组合,从中挑出表现最好的那一组,那它的表现一定是虚高的,这在统计上叫多重比较问题。二十组里总有一两组因为纯运气而表现突出,你挑的就是这个运气。
我的处理办法很简单粗暴:参数只调一次,然后锁死。定位集合的规则、分组方式、过滤条件的组合、打分权重,全部写进配置文件,锁死以后跑满几百期再看。如果中途手痒想改,可以,但那就是新一轮实验,之前的期数清零重来。这个规矩听起来很笨,但它能保证我看到的数字是真实的,而不是我挑出来的。
还有一点:回测的结论只对回测过的数据成立。你在过去五百期上跑出来的任何数字,都不构成对未来的任何承诺。这个道理很简单,但人在看到“命中率2.6%略高于理论值2.3%”的时候,很容易自动把它理解成“有搞头”。我自己的做法是每次回测完就立刻把结果归档,不在当期使用它做任何决策,纯粹把它当作一次工程验证。
5. 实操中踩过的坑与处理习惯
前面四节讲的是方法,这一节讲的是我在真实跑这些脚本的时候,被坑过的几个地方,以及后来养成的处理习惯。这些内容在任何文档里都找不到,全是踩出来的。
5.1 过拟合:参数越调越漂亮的陷阱
这是最要命的一个坑,而且它的表现特别诱人——你每调一次参数,回测结果就好一点,多调几次,命中率从2.3%涨到4%,你会觉得自己的方法论越来越成熟。实际上你只是在让模型记住历史数据的噪声。
我最初就掉进去了。定位集合从“近30期前8”改成“近20期前8”,再改成“近20期前8但剔除上期开出的数字”,每改一次回测都好一点。改到第七八次的时候,我意识到自己在做的事跟“给每期数据配一个专属答案”没什么区别。后来的处理方式是:把参数分成两类,一类是结构参数(比如三个位置留几个数字、分组怎么划),一类是调优参数(比如打分权重)。结构参数固定死不再动,调优参数只在训练集上调,然后用完全没碰过的验证集跑一遍。验证集的数字才是真实水平,而且它通常比训练集难看很多。
提示:如果你发现验证集结果比训练集差一大截,恭喜你,你成功检测到了过拟合。如果两者差不多,也别高兴太早,可能只是样本量不够大、噪声盖住了差异。这两种情况都要继续观察,别急着下结论。
5.2 条件冲突:砍到空了怎么办
第二个坑是条件堆叠到互相冲突。我在一次测试里同时开了“只留组六”“杀和值0到12”“杀和尾0/5/9”“杀断组覆盖度小于3”,四个条件加起来,448注直接砍到4注。这4注理论上还是“符合全部条件”的,但它的覆盖率是0.4%,实际上是退化成了一次纯运气赌博,前面所有的分析都白做了。
后来我加了一道强制检查:每一刀执行后,如果剩余注数低于20注,就打警告并终止,让你回头看看是哪两刀产生了冲突。这个检查在代码里就是一行assert:
def apply_filters_guard(pool, filters, min_keep=20): current = list(pool) for name, fn in filters: current = [n for n in current if fn(n)] if len(current) < min_keep: raise ValueError( f"过滤器 {name} 执行后仅剩 {len(current)} 注,低于下限 {min_keep},请检查条件冲突" ) return current这个min_keep我一般设为20,因为最终目标是23注,如果过滤阶段就已经低于20,说明打分排序根本没有发挥空间,整个流程就退化成“过滤结果直接就是最终结果”了,那排序环节的意义就没了。这时候应该做的是回退力度最大的那一刀,而不是硬着头皮往下走。
5.3 工程细节:日期对齐、去重、复现
最后讲三个看起来琐碎但很影响效率的工程问题。
日期对齐。历史数据的期号和开奖日期必须严格对齐,不能用“第N期”和“第N-1期”这种相对位置来索引。我踩过一次坑:数据源里有一期特别记录(比如销量为零的期次)被跳过,导致期号序列中间缺了一个,后面所有的滚动窗口都错位了一位,回测结果直接全乱。后来我改成用日期做主键,期号只作为展示字段,问题就消失了。
去重。定位大底做笛卡尔积,天然不会重复,因为三个位置是分别取值的。但如果你的数据来源里混进了重复记录(比如补录、重发),大底里就会出现重复的三元组,注数虚高,覆盖率算错。我的做法是在生成大底之后立刻做一次set()去重,并打印去重前后的数量差。正常情况下差值应该是0,如果不是0,说明数据有问题,得先清洗再跑。
复现。前面提过meta快照,这里再强调一次它的重要性。任何一个历史结果,只要给你meta,就能一模一样地重跑出来;没有meta,哪怕是你自己一个月前跑的结果,也复现不了。我现在给每个结果文件都配上两样东西:一个是meta.json(参数快照),一个是trace.csv(每一刀的留存记录)。这两样东西加起来不到1KB,但它决定了你这个项目是“一次性玩具”还是“可累积的实验记录”。
最后分享一个我自己一直在用的检查习惯:每次跑完,先不看最终23注,先看trace表里有没有哪一刀的留存率异常(比如某刀留存率是1.0000,说明它一个都没砍到,可能是条件写错了或者参数没生效)。这个检查花不了十秒钟,但救我过好几次——有一次“杀和尾”那一刀留存率正好是1.0,查了半天发现是banned_tails传了个空元组进去,脚本安安静静地什么都没做,结果却是“看起来正常”的。
我个人的体会是,这类项目的价值不在于最终那23个数字,而在于你能不能把一整套“生成、过滤、打分、回测、归因”的链路搭得干净、可复现、经得起自己回头看。链路搭好了,换成别的问题(比如用户分层、规则引擎、商品筛选)同样能用;链路搭不好,每次都在重复从头写,永远积累不下来。至于结果本身,回到最开始那句话:每期开奖是独立随机事件,任何筛选都改变不了单注概率,把它当成一个练手项目、量力而行就好。