写在最前面
首先郑重声明,这个赚不了钱!赚不了钱!赚不了钱!重要的话说三遍!
纯粹出于兴趣和技术做了个小实验,指望这个赚钱不太可能鸭!emmm,但可能会让你赔钱赔的少一点?
转载请注明出处:https://blog.csdn.net/aaronjny/article/details/103276212
前言
以前从没买过彩票,前几天一时兴起,随机买了几注,然后兴致勃勃地等开奖。中奖序列出来后,比对一下,握草?!
果然没中~
然后我就在考虑机器选彩票的技术可行性。
首先,大乐透的中奖序列为35选5+12选2,每个球的选取是随机的,因此,想使用机器学习精准地预测出获奖序列是很难(或者说基本不可能)的。为什么这么说呢?我们可以类比于机器学习选股票。目前有很多机器学习应用在股票选择上的例子,并能够实现盈利。但机器学习选股和选彩票有几点显著差异:
- 股票只需要预测涨幅(回归)或者简单预测涨跌(二分类),但大乐透中奖序列需要从 C 35 5 ∗ C 12 2 C_{35}^5*C_{12}^2 C355∗C122种(我不是资深彩民,就是兴致一起随手买了两注,所以对各种彩票的规则了解不是很清楚,不清楚是否还有隐含规则,这个是根据字面意思计算得来的)组合中预测出一种,难度完全不是一个量级的
- 股票的涨跌存在各种因子、k线、舆情等参数用于评估和训练,但彩票的中奖序列是随机产生的,可供参考的可能仅有时间上的序列的概率分布。
所谓的概率分布指的是,假设彩票的中奖序列是完全随机产生的话,序列中每一个球在一次开奖过程中出现的概率应该是相同的(前区和后区要分开算),并且从时间序列上来看,连续的很多次开奖中,每一个球的出现与否也应当满足某种规律(当然,这是宏观上讲,实际上肯定不会严格满足,但能够体现某种趋势或倾向)。
而我们又不打算靠这个中大奖,目标不是预测出头等奖的开奖序列,而是尽可能多的预测出可能会出现在中奖序列中的球。听上去好像没什么区别,但在模型设计上能够体现出差异——如果是前者,我们的模型应该是一个分类器,从 C 35 5 ∗ C 12 2 C_{35}^5*C_{12}^2 C355∗C122种类别中预测出其中一个,但考虑数据集规模和概率问题,根本不现实;而后者,我们则设计一个多输入、多输出的模型,输入是七个球的时间序列,输出是每个位置出现某个球的概率,这样就靠谱的多~emmm,我们不求赚钱,少输点就行~!
行嘛,想了想好像没什么问题,那就开搞?于是,就有了这篇文章。
一、获取数据
想训练个模型的话,第一步肯定是获取数据啦。
我在网上找了一下,很快从[江苏体彩网](https://www.js-lottery.com/Pla 
yZone/lottoData.html)找到了历史开奖记录,可以直接下载,文件格式为csv。
这就很舒服啦,不用写爬虫到处一点点爬了。把文件下载下来看一下,数据是以这种形式组织的:
很明显,最前面是期号,然后是前区的五个号码,最后是后区的两个号码。
ok,下面让我们来写一个下载数据集的脚本,以便于我们对数据进行更新:
# -*- coding: utf-8 -*-
# @File : update_data.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc :
import requests
import settingsprint('开始尝试从 {} 获取最新的大乐透数据...'.format(settings.LOTTO_DOWNLOAD_URL))
try:resp = requests.get(settings.LOTTO_DOWNLOAD_URL)if resp.status_code == 200:# 解析数据,查看数据集中最新的数据期数lines = resp.content.decode('utf-8').split('\n')index = lines[0].replace('"', '').split(',')[0]print('获取成功!开始更新文件...')with open(settings.DATASET_PATH, 'wb') as f:f.write(resp.content)print('完成!当前最新期数为{}期,请确认期数是否正确!'.format(index))else:raise Exception('获取数据失败!')
except Exception as e:print(e)
我把一些常量提取出来了,放到了settings.py
里,需要对照的话请看:
# -*- coding: utf-8 -*-
# @File : settings.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc :# 训练epochs数量
EPOCHS = 60
# 训练批大小
BATCH_SIZE = 128
# 输入的连续时间序列长度
MAX_STEPS = 256
# 前区号码种类数
FRONT_VOCAB_SIZE = 35
# 后区号码种类数
BACK_VOCAB_SIZE = 12
# dropout随机失活比例
DROPOUT_RATE = 0.5
# 长短期记忆网络单元数
LSTM_UNITS = 64
# 前区需要选择的号码数量
FRONT_SIZE = 5
# 后区需要选择的号码数量
BACK_SIZE = 2
# 保存训练好的参数的路径
CHECKPOINTS_PATH = 'checkpoints'
# 预测下期号码时使用的训练好的模型参数的路径,默认使用完整数据集训练出的模型
PREDICT_MODEL_PATH = '{}/model_checkpoint_x'.format(CHECKPOINTS_PATH)
# 预测的时候,预测几注彩票,默认5注
PREDICT_NUM = 5
# 数据集路径
DATASET_PATH = 'lotto.csv'
# 数据集下载地址
LOTTO_DOWNLOAD_URL = 'https://www.js-lottery.com/PlayZone/downLottoData.html'
# 大乐透中奖及奖金规则(没有考虑浮动情况,税前)
AWARD_RULES = {(5, 2): 10000000,(5, 1): 800691,(5, 0): 10000,(4, 2): 3000,(4, 1): 300,(3, 2): 200,(4, 0): 100,(3, 1): 15,(2, 2): 15,(3, 0): 5,(2, 1): 5,(1, 2): 5,(0, 2): 5
}
我们尝试运行一下,看看效果:
很好,没有问题。
二、处理数据
虽然数据已经获取到了,但显然,这个数据无法直接应用于训练。我们需要对数据做一下简单的处理。
现在,我们为中奖序列中的数字(或者说球)编一下号,从前往后它们的编号分别为1到7,其中1-5是前区的5个球,6-7是后区的2个球。
好的,我们现在先考虑另外一个问题,假如我们有近一年以来的气温数据,需要预测明天的气温,应该怎么做?
你可能会脱口而出,用循环神经网络做序列的预测。假设按顺序给这近一年来的气温分别编号为1-365,其中t1表示第一天的气温,t365表示今天的气温。气温的变化应该是有规律的(一般情况下),我们想让机器来学习这种规律。我们选定一个合适的时间长度,比如30天,然后将这30天的连续数据作为输入(x),将接下来一天的气温数据作为输出(y),就构成了一条数据。然后使用长度为31天的扫描框,对一年的数据进行一次遍历,我们就得到了一组数据集。用它进行训练,完成后,输入30天前到今天的气温序列,即可预测明天的气温。
回到这个问题上来,其实和预测气温差不多,我们使用连续若干期的球1的数据来预测下期球1的分布概率,球2-球7都是同样的方法。单从输入输出看来就是这样,实际上实现起来肯定会有更多的处理和优化,在这里不讨论,说到模型的时候再细说。
和预测气温的例子不同,气温预测时只有一种因子参与,就是当天的气温值。而在这个例子里,输入的是7个球,输出的也是7个概率分布,所以这是个多输入、多输出的模型。我准备使用keras来实现模型,按照keras的接口,我需要把输入数据处理成这个格式:
x={'x1': [球1序列1, 球1序列2, ... , 球1序列n], 'x2': [球2序列1, 球2序列2, ... , 球2序列n], ... , 'x7': [球7序列1, 球7序列2, ... , 球7序列n]}
相应的,输出数据整理成这个格式:
y={'y1': [序列1的下一期球1值, 序列2的下一期球1值, ... 序列n的下一期球1值], 'y2': [序列1的下一期球2值, 序列2的下一期球2值, ... 序列n的下一期球2值], ... ,'y7': [序列1的下一期球7值, 序列2的下一期球7值, ... 序列n的下一期球7值], }
开搞!
# -*- coding: utf-8 -*-
# @File : dataset.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc : 对数据集进行相关处理
import time
import numpy as np
import settingsclass LottoDataSet:def __init__(self, path=settings.DATASET_PATH, train_data_rate=0.9, shuffle=True):# 数据集路径self.path = path# 训练集占整体数据集的比例self.train_data_rate = train_data_rate# 是否打乱数据集顺序self.shuffle = shuffle# 训练集self.train_np_x = {}self.train_np_y = {}# 测试集self.test_np_x = {}self.test_np_y = {}# 加载并处理数据集self.clean_data()def load_data_from_path(self, path=None):"""从给定路径加载数据集:param path: 数据集路径:return: list,若干组开奖记录"""# 如果没有指定路径,就是用初始化实例时传递的pathif not path:path = self.path# 读取数据行with open(path) as f:# 因为csv里面最新的数据放在了最前面,所以我们需要颠倒一下lines = f.readlines()[::-1]# 排除空行lines = [line.strip() for line in lines if line.strip()]return linesdef clean_data(self):"""对数据进行清洗:return:"""# 先从硬盘读取文件lines = self.load_data_from_path()# 去除引号,并使用逗号分割,将数据转成数组x_nums = []for line in lines:# 下标0的位置是期号,直接去掉nums = line.replace('"', '').split(',')[1:]# 所有球的编号都减一,把1-35变成0-34,1-12变成0-11# 这样便于后面做softmaxx_nums.append([int(x) - 1 for x in nums])# 接着,把中奖序列中的七个数字拆开,按位置和时间纵轴组合,变成7组数据num_seqs = {}# 对于每一期的中奖序列for line in x_nums:# 对于一条中奖序列中的每一个数for index, num in enumerate(line):# 最后的数据格式{0: [1,2,3,4,...],1: [1,2,3,4,...],...,6: [1,2,3,4,...]}num_seqs.setdefault(index, []).append(num)# 根据时间序列,拆出来x和y数据集,每MAX_STEPS长度的连续序列构成一条数据的x,max_steps+1构成y# 举例,假设MAX_STEPS=3,有序列[1,2,3,4,5,6],则[1,2,3->4],[2,3,4->5],[3,4,5->6]是组成的数据集x = {}y = {}for index, seqs in num_seqs.items():x[index] = []y[index] = []total = len(seqs)# 序列的长度要求为MAX_STEPS,所以从MAX_STEPS处开始,而不是下标0处for i in range(settings.MAX_STEPS, total, 1):# 存放本条x序列的,存放的是数字形式tmp_x = []# 存放本条y值的,存放的one-hot形式,虽然y只是一个数,但one-hot形式也为listif index < settings.FRONT_SIZE:# 根据index判断当前号码属于前区还是后区,使用相关的号码种类数量来初始化one-hot向量# 因为前区是35选5,后区是12选2,one-hot向量大小不同,所以要区别对待tmp_y = [0 for _ in range(settings.FRONT_VOCAB_SIZE)]else:tmp_y = [0 for _ in range(settings.BACK_VOCAB_SIZE)]# 将从i-MAX_STEPS到i(不包括i)的这一段长为MAX_STEPS的序列,逐个加入到tmp_x中for j in range(i - settings.MAX_STEPS, i, 1):tmp_x.append(seqs[j])# 将这条记录添加到x数据集中x[index].append(tmp_x)# 修改y值的one-hot,并将标签加入到y数据集中tmp_y[seqs[i]] = 1y[index].append(tmp_y)# y在前面已经是one-hot形式了,我们现在需要把x里面的数字也转成one-hot形式,并转成numpy的array类型np_x = {}np_y = {}# 对应7个球构成的七组序列中的每一组for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):# 获取样本数量x_len = len(x[i])# 根据球所处的前后区,分别进行初始化if i < settings.FRONT_SIZE:tmp_x = np.zeros((x_len, settings.MAX_STEPS, settings.FRONT_VOCAB_SIZE))tmp_y = np.zeros((x_len, settings.FRONT_VOCAB_SIZE))else:tmp_x = np.zeros((x_len, settings.MAX_STEPS, settings.BACK_VOCAB_SIZE))tmp_y = np.zeros((x_len, settings.BACK_VOCAB_SIZE))# 分别利用x,y中的数据修改tmp_x和tmp_yfor j in range(x_len):for k, num in enumerate(x[i][j]):tmp_x[j][k][num] = 1for k, num in enumerate(y[i][j]):tmp_y[j][k] = num# 然后将tmp_x和tmp_y按照球所处的位置,加入到np_x和np_y中np_x['x{}'.format(i + 1)] = tmp_xnp_y['y{}'.format(i + 1)] = tmp_y# ok,现在我们可以看一下数组的shape是否正确# for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):# print(i + 1, np_x['x{}'.format(i + 1)].shape, np_y['y{}'.format(i + 1)].shape)# 划分数据集total_batch = len(np_x['x1']) # 总样本数train_batch_num = int(total_batch * self.train_data_rate) # 训练样本数train_np_x = {}train_np_y = {}test_np_x = {}test_np_y = {}for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):x_index = 'x{}'.format(i + 1)y_index = 'y{}'.format(i + 1)train_np_x[x_index] = np_x[x_index][:train_batch_num]train_np_y[y_index] = np_y[y_index][:train_batch_num]test_np_x[x_index] = np_x[x_index][train_batch_num:]test_np_y[y_index] = np_y[y_index][train_batch_num:]# 打乱训练数据if self.shuffle:random_seed = int(time.time())# 使用相同的随机数种子,保证x和y的一一对应没有被破坏for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):np.random.seed(random_seed)np.random.shuffle(train_np_x['x{}'.format(i + 1)])np.random.seed(random_seed)np.random.shuffle(train_np_y['y{}'.format(i + 1)])self.train_np_x = train_np_xself.train_np_y = train_np_yself.test_np_x = test_np_xself.test_np_y = test_np_y@propertydef predict_data(self):"""模型训练好之后,获取预测下期彩票序列(未发生事件)时使用的输入数据.数据处理方式与clean_data方法相似,但只返回最新的连续的MAX_STEPS期开奖序列的x值:return:"""# 先从硬盘读取文件lines = self.load_data_from_path()# 去除引号,并使用逗号分割,将数据转成数组x_nums = []for line in lines:# 下标0的位置是期号,直接去掉nums = line.replace('"', '').split(',')[1:]# 所有球的编号都减一,把1-35变成0-34,1-12变成0-11# 这样便于后面做softmaxx_nums.append([int(x) - 1 for x in nums])# 接着,把中奖序列中的七个数字拆开,按位置和时间纵轴组合,变成7组数据num_seqs = {}# 对于每一期的中奖序列for line in x_nums:# 对于一条中奖序列中的每一个数for index, num in enumerate(line):# 最后的数据格式{0: [1,2,3,4,...],1: [1,2,3,4,...],...,6: [1,2,3,4,...]}num_seqs.setdefault(index, []).append(num)# 根据时间序列,拆出来x和y数据集,每MAX_STEPS长度的连续序列构成一条数据的x,max_steps+1构成y# 举例,假设MAX_STEPS=3,有序列[1,2,3,4,5,6],则[1,2,3->4],[2,3,4->5],[3,4,5->6]是组成的数据集x = {}for index, seqs in num_seqs.items():x[index] = []total = len(seqs)# 存放本条x序列的,存放的是数字形式tmp_x = []# 将从i-MAX_STEPS到i(不包括i)的这一段长为MAX_STEPS的序列,逐个加入到tmp_x中for j in range(total - settings.MAX_STEPS, total, 1):tmp_x.append(seqs[j])# 将这条记录添加到x数据集中x[index].append(tmp_x)# 我们现在需要把x里面的数字也转成one-hot形式,并转成numpy的array类型np_x = {}# 对应7个球构成的七组序列中的每一组for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):# 获取样本数量x_len = len(x[i])# 根据球所处的前后区,分别进行初始化if i < settings.FRONT_SIZE:tmp_x = np.zeros((x_len, settings.MAX_STEPS, settings.FRONT_VOCAB_SIZE))else:tmp_x = np.zeros((x_len, settings.MAX_STEPS, settings.BACK_VOCAB_SIZE))# 分别利用x,y中的数据修改tmp_x和tmp_yfor j in range(x_len):for k, num in enumerate(x[i][j]):tmp_x[j][k][num] = 1# 然后将tmp_x和tmp_y按照球所处的位置,加入到np_x和np_y中np_x['x{}'.format(i + 1)] = tmp_xreturn np_x
需要提一下的是,虽然每个球上的编号都是数字,但我们不应该把它们当数字,因为它们的大小关系在开奖中没有任何意义,且在训练中可能会对模型产生干扰。因此我们选择使用one-hot形式以类别的方式来表示它们,而不是一个数值。
同时,为了softmax和one-hot方便,我将球上的数字都减了一,把1-35变成0-34,1-12变成0-11。
三、编写模型
先给出模型示意图,再细说模型,可能会更清楚一些:
前面提到“和预测气温差不多,我们使用连续若干期的球1的数据来预测下期球1的分布概率,球2-球7都是同样的方法”,但因为这些球本身并不独立,比如球1开出了3,球2-5就不可能再开出3,而是在剩下的里面选。所以我们在预测最后的概率之前,对球1-5的中间层进行了拼接,再分别预测,这样模型可能会学习到每一期中前区的球之间的某种关系。对于球6和7,也做了类似操作。
而球1-5在前区,球6-7在后区,两者没什么关系,所以这两部分之间没有进行拼接。
另外,最后的输出预测层选择了softmax,值得说一下。严格来说,softmax对于这个问题来说,并不是一个很好地选择,因为开球应该是条件概率,比如球1开了5之后,开球2的概率计算应该是球1==5
条件下的条件概率,球3-5同理。但我最终还是选择了softmax,原因一是softmax实现起来更加简单,二是模型输出本身设计的就不是预测头等奖的完全正确序列,而是尽可能多的选中球,两者的区别前面提过。这样看来,softmax也还算合适,大不了重复了就使用轮盘赌法重新选。
其他的就没什么好说的了,模型示意图已经表现的很清楚了,更多的无非是层的选择罢了,直接看代码吧:
# -*- coding: utf-8 -*-
# @File : models.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc : 建立深度学习模型
import keras
from keras import layers
from keras import models
import settings# 这是一个多输入模型,inputs用来保存所有的输入层
inputs = []
# 这是一个多输出模型,outputs用来保存所有的输出层
outputs = []
# 前区的中间层列表,用于拼接
front_temps = []
# 后区的中间层
back_temps = []# 处理前区的输入变换
for i in range(settings.FRONT_SIZE):# 输入层x_input = layers.Input((settings.MAX_STEPS, settings.FRONT_VOCAB_SIZE), name='x{}'.format(i + 1))# 双向循环神经网络x = layers.Bidirectional(layers.LSTM(settings.LSTM_UNITS, return_sequences=True))(x_input)# 随机失活x = layers.Dropout(rate=settings.DROPOUT_RATE)(x)x = layers.Bidirectional(layers.LSTM(settings.LSTM_UNITS, return_sequences=True))(x)x = layers.Dropout(rate=settings.DROPOUT_RATE)(x)x = layers.TimeDistributed(layers.Dense(settings.FRONT_VOCAB_SIZE * 3))(x)# 平铺x = layers.Flatten()(x)# 全连接x = layers.Dense(settings.FRONT_VOCAB_SIZE * 3, activation='relu')(x)# 保存输入层inputs.append(x_input)# 保存前区中间层front_temps.append(x)
# 处理后区的输入和变换
for i in range(settings.BACK_SIZE):# 输入层x_input = layers.Input((settings.MAX_STEPS, settings.BACK_VOCAB_SIZE),name='x{}'.format(i + 1 + settings.FRONT_SIZE))# 双向循环神经网络x = layers.Bidirectional(layers.LSTM(settings.LSTM_UNITS, return_sequences=True))(x_input)# 随机失活x = layers.Dropout(rate=settings.DROPOUT_RATE)(x)x = layers.Bidirectional(layers.LSTM(settings.LSTM_UNITS, return_sequences=True))(x)x = layers.Dropout(rate=settings.DROPOUT_RATE)(x)x = layers.TimeDistributed(layers.Dense(settings.BACK_VOCAB_SIZE * 3))(x)# 平铺x = layers.Flatten()(x)# 全连接x = layers.Dense(settings.BACK_VOCAB_SIZE * 3, activation='relu')(x)# 保存输入层inputs.append(x_input)# 保存后区中间层back_temps.append(x)
# 连接
front_concat_layer = layers.concatenate(front_temps)
back_concat_layer = layers.concatenate(back_temps)
# 使用softmax计算分布概率
for i in range(settings.FRONT_SIZE):x = layers.Dense(settings.FRONT_VOCAB_SIZE, activation='softmax', name='y{}'.format(i + 1))(front_concat_layer)outputs.append(x)
for i in range(settings.BACK_SIZE):x = layers.Dense(settings.BACK_VOCAB_SIZE, activation='softmax', name='y{}'.format(i + 1 + settings.FRONT_SIZE))(back_concat_layer)outputs.append(x)
# 创建模型
model = models.Model(inputs, outputs)
# 指定优化器和损失函数
model.compile(optimizer=keras.optimizers.Adam(),loss=[keras.losses.categorical_crossentropy for __ in range(settings.FRONT_SIZE + settings.BACK_SIZE)],loss_weights=[2, 2, 2, 2, 2, 1, 1])
# 查看网络结构
model.summary()# 可视化模型,保存结构图
# from keras.utils import plot_model
# plot_model(model, to_file='model.png')
可视化模型部分,保存的就是上面那张模型图,因为需要额外的依赖,我就给注释掉了。如果确实需要执行的话,请自行安装相关依赖。
四、工具方法
数据和模型都已经准备完毕,可以进行训练了。但这个模型不同于一般的分类模型,我们怎么来评估模型的效果呢?我选择的方法是——回测。
其实很简单,就是划分一部分数据(比如90%)作为训练集,训练模型,剩下的10%作为测试集。划分是按照时间顺序划分的,保证后面10%的数据绝不出现在训练集的结果数据或过程数据中。在使用训练集完成模型的训练后,我们对测试集进行预测,并按照预测结果购买彩票,计算支出和奖金,以最终的净收入的多少来衡量模型效果。
现在,我们需要编写一些工具方法,辅助我们完成回测。
# -*- coding: utf-8 -*-
# @File : utils.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc : 对数据进行处理和操作的一些工具方法
import matplotlib.pyplot as plt
import numpy as np
import settingsdef sample(preds, temperature=1.0):"""从给定的preds中随机选择一个下标。当temperature固定时,preds中的值越大,选择其下标的概率就越大;当temperature不固定时,temperature越大,选择值小的下标的概率相对提高,temperature越小,选择值大的下标的概率相对提高。:param preds: 概率分布序列,其和为1.0:param temperature: 当temperature==1.0时,相当于直接对preds进行轮盘赌法:return:"""preds = np.asarray(preds).astype(np.float64)preds = np.log(preds) / temperatureexp_preds = np.exp(preds)preds = exp_preds / np.sum(exp_preds)probas = np.random.multinomial(1, preds, 1)return np.argmax(probas)def search_award(front_match_num, back_match_num, cache={}):"""给定前后区命中数量,使用记忆化搜索查找并计算对应奖金:param front_match_num: 前区命中数量:param back_match_num: 后区命中数量:param cache: 缓存用的记忆字典:return:"""# 前后区都没有命中,奖金为0if front_match_num == 0 and back_match_num == 0:return 0# 尝试直接从缓存里面获取奖金award = cache.get((front_match_num, back_match_num), -1)# 这里使用-1是为了避免0和None在判断上的混淆# 如果缓存里面有,已经计算过了,就直接返回if award != -1:return award# 尝试直接从中奖规则中获取奖金数量award = settings.AWARD_RULES.get((front_match_num, back_match_num), -1)if award == -1:# 如果没找到,就先认为没中奖,然后将前区命中数量或后区命中数量减一,# 递归查找,保留最大的中奖金额award = 0if front_match_num > 0:award = search_award(front_match_num - 1, back_match_num)if back_match_num > 0:award = max(award, search_award(front_match_num, back_match_num - 1))# 缓存下本次计算结果cache[(front_match_num, back_match_num)] = award# 返回奖金数额return awarddef lotto_calculate(winning_sequence, sequence_selected):"""给定中奖序列和选择的序列,计算获奖金额:param winning_sequence:中奖序列:param sequence_selected: 选择的序列:return:"""# 前区命中数量front_match = len(set(winning_sequence[:settings.FRONT_SIZE]).intersection(set(sequence_selected[:settings.FRONT_SIZE])))# 后区命中数量back_match = len(set(winning_sequence[settings.FRONT_SIZE:]).intersection(set(sequence_selected[settings.FRONT_SIZE:])))# 计算奖金award = search_award(front_match, back_match)return awarddef select_seqs(predicts):"""根据给定的概率分布,随机选择一种彩票序列:param predicts:list[list] 每一个球的概率分布组成的列表:return: list 彩票序列"""balls = []# 对于每一种球for predict in predicts:try_cnt = 0while True:try_cnt += 1# 根据预测结果随机选择一个if try_cnt < 100:ball = sample(predict)else:# 如果连续100次都是重复的,就等概率地从所有球里面选择一个ball = sample([1. / len(predict) for __ in predict])# 如果选重复了就重新选if ball in balls:# 序列不长,就没有使用set优化,直接用list了continue# 将球保存下来,跳出,开始选取下一个balls.append(ball)break# 排序,前五个升序,后两个升序balls = sorted(balls[:settings.FRONT_SIZE]) + sorted(balls[settings.FRONT_SIZE:])return ballsdef draw_graph(y):"""绘制给定列表y的折线图和趋势线"""# 横坐标,第几轮训练x = list(range(len(y)))# 拟合一次函数,返回函数参数parameter = np.polyfit(x, y, 1)# 拼接方程f = np.poly1d(parameter)# 绘制图像plt.plot(x, f(x), "r--")plt.plot(y)plt.show()
五、训练模型
数据集、模型和工具方法已经全部写好了,可以正式开始训练了。
我们将数据集按照训练集:测试集=9:1
的比例划分数据集,在训练集上训练模型,并使用测试集回测。我准备训练60轮,每一轮训练完成后,都会保存模型的参数,并进行回测。
在训练结束后,将所有回测结果,按时间顺序,绘制出折线图和趋势线。
# -*- coding: utf-8 -*-
# @File : train.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc :
import os
import numpy as np
from models import model
from dataset import LottoDataSet
import settings
import utilsdef simulate(test_np_x, test_np_y):"""模拟购买彩票,对测试数据进行回测:param test_np_x: 测试数据输入:param test_np_y: 测试数据输出:return: 本次模拟的净收益"""# 获得的奖金总额money_in = 0# 买彩票花出去的钱总额money_out = 0# 预测predicts = model.predict(test_np_x, batch_size=settings.BATCH_SIZE)# 共有多少组数据samples_num = len(test_np_x['x1'])# 对于每一组数据for j in range(samples_num):# 这一期的真实开奖结果outputs = []for k in range(settings.FRONT_SIZE + settings.BACK_SIZE):outputs.append(np.argmax(test_np_y['y{}'.format(k + 1)][j]))# 每一期彩票买五注money_out += 10for k in range(5):# 存放每个球的概率分布的listprobabilities = []# 对于每一种球,将其概率分布加入到列表中去for i in range(settings.FRONT_SIZE + settings.BACK_SIZE):probabilities.append(predicts[i][j])# 根据概率分布随机选择一个序列balls = utils.select_seqs(probabilities)# 计算奖金award = utils.lotto_calculate(outputs, balls)money_in += awardif award:print('{} 中奖了,{}元! {}/{}'.format(j, award, money_in, money_out))print('买彩票花费金钱共{}元,中奖金额共{}元,赚取{}元'.format(money_out, money_in, money_in - money_out))return money_in - money_out# 初始化数据集
lotto_dataset = LottoDataSet(train_data_rate=0.9)
# 创建保存权重的文件夹
if not os.path.exists(settings.CHECKPOINTS_PATH):os.mkdir(settings.CHECKPOINTS_PATH)
# 开始训练
results = []
for epoch in range(1, settings.EPOCHS + 1):model.fit(lotto_dataset.train_np_x, lotto_dataset.train_np_y, batch_size=settings.BATCH_SIZE, epochs=1)# 保存当前权重model.save_weights('{}/model_checkpoint_{}'.format(settings.CHECKPOINTS_PATH, epoch))print('已训练完第{}轮,尝试模拟购买彩票...'.format(epoch))results.append(simulate(lotto_dataset.test_np_x, lotto_dataset.test_np_y))
# 输出每一轮的模拟结果
print(results)
# 显示每一轮模拟结果的变化趋势
utils.draw_graph(results)
有几点需要注意:
- 在GPU上进行训练,尽量避免在CPU上训练。一般使用GPU训练在速度上是优于CPU的。当然,如果你的显卡很弱鸡,CPU很强大的话,那就选择CPU吧。
- 如果你的内存比较小(用CPU的话就是内存,用GPU就是显存),请适量调小训练的batch大小。
- 如果看到赚到的钱是负的,请不要惊讶,前面已经声明过了,这个模型的目的是尽量少赔点~滑稽.jpg
回测时的输出大致是这样的(输出比较长,截取一部分):
已训练完第17轮,尝试模拟购买彩票...
6 中奖了,5元! 5/70
8 中奖了,5元! 10/90
8 中奖了,5元! 15/90
10 中奖了,5元! 20/110
12 中奖了,15元! 35/130
23 中奖了,5元! 40/240
24 中奖了,5元! 45/250
24 中奖了,5元! 50/250
25 中奖了,5元! 55/260
33 中奖了,5元! 60/340
36 中奖了,15元! 75/370
38 中奖了,5元! 80/390
41 中奖了,5元! 85/420
44 中奖了,5元! 90/450
46 中奖了,5元! 95/470
51 中奖了,15元! 110/520
51 中奖了,5元! 115/520
54 中奖了,5元! 120/550
61 中奖了,5元! 125/620
61 中奖了,5元! 130/620
62 中奖了,5元! 135/630
62 中奖了,5元! 140/630
67 中奖了,5元! 145/680
75 中奖了,5元! 150/760
76 中奖了,5元! 155/770
84 中奖了,5元! 160/850
87 中奖了,5元! 165/880
88 中奖了,5元! 170/890
88 中奖了,5元! 175/890
88 中奖了,15元! 190/890
90 中奖了,5元! 195/910
93 中奖了,5元! 200/940
96 中奖了,15元! 215/970
107 中奖了,5元! 220/1080
114 中奖了,5元! 225/1150
115 中奖了,5元! 230/1160
120 中奖了,100元! 330/1210
123 中奖了,5元! 335/1240
123 中奖了,200元! 535/1240
123 中奖了,15元! 550/1240
125 中奖了,5元! 555/1260
125 中奖了,5元! 560/1260
133 中奖了,5元! 565/1340
136 中奖了,5元! 570/1370
136 中奖了,5元! 575/1370
141 中奖了,5元! 580/1420
142 中奖了,15元! 595/1430
147 中奖了,5元! 600/1480
147 中奖了,5元! 605/1480
149 中奖了,15元! 620/1500
149 中奖了,5元! 625/1500
153 中奖了,5元! 630/1540
155 中奖了,15元! 645/1560
155 中奖了,5元! 650/1560
160 中奖了,15元! 665/1610
164 中奖了,5元! 670/1650
买彩票花费金钱共1660元,中奖金额共670元,赚取-990元
我试着跑了几次,给出几个我跑出来的结果:
[-1335, -1305, -1360, -1420, -1215, -1090, -1140, -1395, -1310, -1355, -1220, -1095, -1375, -1420, -1255, -1270, -730, -1155, -1360, -1330, -1140, -1090, -1030, -1340, -1060, -1150, -1285, -935, -1175, -1290, -1260, -1075, -1275, -1275, -870, -1275, -890, -1175, -1265, -1235, -1260, -1265, -1255, -1270, -1170, -660, -1015, -915, -1095, -850, -560, -890, -980, -670, -1185, -510, -1110, -470, -1180, -655]
这算是一个比较符合预期的结果?虽然还是在赔钱,但达到了我们的目的——少赔点钱=。=虽然一直在赔钱,但随着训练次数的增加,亏损金额在整体趋势上逐步减少。
[-1095, -1345, -1405, -1390, -1265, -1055, -970, -1375, -1205, -1365, -1260, -1305, -1120, -1280, -1125, -1370, -860, -1285, -1160, -1065, -1295, -1105, -765, -1160, -1055, -1180, -780, -1200, -1205, -760, -1075, -1105, -1130, -955, -1105, -1170, -1140, -915, -735, 8785, 9065, -1035, -1145, -635, -785, 8935, 799876, -995, -1010, -1130, -1125, -1170, -1255, -1035, -920, -935, -1090, -1330, 8930, -1370]
这个是比较容易血压升高的结果?有一次回测的过程中中了80万…因为80万跟其他回测结果差距太大了,所以图像上基本显示不出其他轮次的起伏了。我们把最高的结果减去79万,看一下其他轮次的趋势:
这个看起来就顺眼多了。
多次运行的结果可能差距明显,其原因分析如下:
- 训练数据的原因。前面已经说过了,彩票选号其实是没有严格的规律可言的,否则,哪怕只有极少数一批人能稳定猜中,这个游戏也没法玩啊。如果非要强行说个规律出来,那也只有长期下来的概率分布能勉强凑合。但一来大乐透也只开了一千多期,数据有限,二来,概率这种东西从字面上来看,就知道它不是固定的(哪怕出现的概率最高,也不一定会出现)。这样,当模型的随机初始权重不同,训练数据又很难找到特别清晰的规律时,模型学习到的东西也会产生相应的区别,它们分别倾向到了概率分布的不同表现形式。
- 回测时选择彩票号码的原因。选择号码时,同样不是一定选择出现概率最大的球,只是出现概率越大,被选中的概率就越大,这样保证了结果的多样性。
两者综合起来,两次的运行结果可能天差地别。但从多次运行的整体来看,还是有一定规律的:
- 训练一定次数之后,亏损金额大多分布在[-1200,-900]左右,少数情况下在(-900,-400],极少数甚至还有得赚。
- 大部分都满足“随着训练次数的增加,损失逐步减少”的规律。注意,这里指的是趋势,即图中拟合的一次函数(一条斜直线),因为回测的随机性,单点结果是会出现起伏波动的,所以使用趋势来衡量整体结果会更加合适。
综上,我们的模型应该是起到了一定作用。
你可能说,这亏的也不少啊,我怎么看出来模型到底有没有效果呢?那我们写一个基线模型,来比较一下。
六、基线模型
什么是基线模型?
emmm,怎么说呢,它指的是一个最基础、最简单的模型,它是从概率的角度上来说最糟糕的一个模型。可能解释的不是很清楚,我们直接看例子。
一般极限模型就是都是完全随机的。比如这个问题,我们需要从前区选出五个球,后区选出两个球,我们每个球都随机选择,这就是基线模型。emmm,它类似于彩票中心的机选方案?
我们来实现一下基线模型,并模拟多次购买彩票经历,看使用基线模型我们会亏多少:
# -*- coding: utf-8 -*-
# @File : random_show.py
# @Author: AaronJny
# @Date : 2019/10/29
# @Desc : 随机选择情况下的收益情况
import random
import numpy as np
from dataset import LottoDataSet
import utils
import settingsdef get_one_random_sample():"""获取一种随机序列:return:"""front_balls = list(range(settings.FRONT_VOCAB_SIZE))back_balls = list(range(settings.BACK_VOCAB_SIZE))return random.sample(front_balls, settings.FRONT_SIZE) + random.sample(back_balls, settings.BACK_SIZE)def simulate(test_np_x, test_np_y):# 获得的奖金总额money_in = 0# 买彩票花出去的钱总额money_out = 0# 共有多少组数据samples_num = len(test_np_x['x1'])# 对于每一组数据for j in range(samples_num):# 这一期的真实开奖结果outputs = []for k in range(settings.FRONT_SIZE + settings.BACK_SIZE):outputs.append(np.argmax(test_np_y['y{}'.format(k + 1)][j]))# 每一期彩票买五注money_out += 10for k in range(5):balls = get_one_random_sample()# 计算奖金award = utils.lotto_calculate(outputs, balls)money_in += awardprint('买彩票花费金钱共{}元,中奖金额共{}元,赚取{}元'.format(money_out, money_in, money_in - money_out))return money_in - money_outdataset = LottoDataSet(train_data_rate=0.9)
# 随机买一百次,并记录每一次收入-支出的差值
results = []
for epoch in range(1, 101):results.append(simulate(dataset.test_np_x, dataset.test_np_y))
# 去除最高的和最低的
results = sorted(results)[1:-1]
# 计算平均值
print('mean', sum(results) / len(results))
运行一下,输出的结果是这样的:
多次运行可以发现,最后的平均值绝大多数落在[-1400,-1200]之间,其中又以-1250左右最多。少数亏得更少或更多,极少数能够小赚。
这样比较下来,我们写的模型还是有用的?至少能少亏一点?滑稽.jpg
平时事情也比较多,所以模型只是大概调了一下,如果对此有兴趣的话,也可以在此基础上,再自行调一下参看看。
七、预测下期彩票序列
如果准备利用模型买彩票,可以分为两种情况:
- 1.选择我们在上一步训练好的某个模型参数,加载这个参数,输入倒数第
MAX_STEPS
期到最近一期的数据序列,预测下一期序列。 - 2.使用完整数据集作为训练集,重新训练模型并保存。然后和第一种情况一样,加载模型参数,输入倒数第
MAX_STEPS
期到最近一期的数据序列,预测下一期序列。
两者的区别在于:
- 第一种情况,我们有回测数据,在选择训练好的参数时有一定的参考。而第二种情况使用了完整数据集来训练,就没有回测数据可参考了。
- 第一种情况的训练数据,少于第二种的训练数据。按理说更多的训练数据通常会产生更好的效果。
各有优缺点吧,酌情选择。但不管怎么样,我们都来实现一下完整训练的脚本:
# -*- coding: utf-8 -*-
# @File : train_with_whole_dataset.py
# @Author : AaronJny
# @Date : 2019/11/26
# @Desc : 使用全部数据集进行训练
import os
from models import model
from dataset import LottoDataSet
import settings# 初始化数据集
lotto_dataset = LottoDataSet(train_data_rate=1)
# 创建保存权重的文件夹
if not os.path.exists(settings.CHECKPOINTS_PATH):os.mkdir(settings.CHECKPOINTS_PATH)
# 开始训练
model.fit(lotto_dataset.train_np_x, lotto_dataset.train_np_y, batch_size=settings.BATCH_SIZE, epochs=settings.EPOCHS)
# 保存模型
model.save_weights('{}/model_checkpoint_x'.format(settings.CHECKPOINTS_PATH))
好的,不论你选择用哪种方法训练出的模型,都没有关系,我们来看看如何让模型帮我们选号码。
# -*- coding: utf-8 -*-
# @File : predict.py
# @Author : AaronJny
# @Date : 2019/11/26
# @Desc : 指定一个训练好的模型参数,让模型随机选出下期彩票号码
from dataset import LottoDataSet
from models import model
import settings
import utils# 加载模型参数
model.load_weights(settings.PREDICT_MODEL_PATH)
# 构建数据集
lotto_dataset = LottoDataSet()
# 提取倒数第MAX_STEPS期到最近一期的数据,作为预测的输入
x = lotto_dataset.predict_data
# 开始预测
predicts = model.predict(x, batch_size=1)
# 存放选号结果的列表
result = []
# 存放每个球的概率分布的list
probabilities = [predict[0] for predict in predicts]
# print(probabilities)
# 总共要选出settings.PREDICT_NUM注彩票
for i in range(settings.PREDICT_NUM):# 根据概率分布随机选择一个序列balls = utils.select_seqs(probabilities)# 加入到选号列表中,注意,我们需要把全部的数字+1,恢复原始的编号result.append([ball + 1 for ball in balls])
# 输出要买的彩票序列
print('本次预测结果如下:')
for index, balls in enumerate(result, start=1):print('第{}注 {}'.format(index, ' '.join(map(str, balls))))
模型默认加载使用完整数据作为训练集的模型参数,如果想要加载其他指定参数,直接修改settings中的PREDICT_MODEL_PATH
即可。
运行一下,模型输出:
本次预测结果如下:
第1注 2 5 19 25 26 1 11
第2注 2 5 19 26 28 1 12
第3注 1 5 19 21 26 7 11
第4注 2 5 19 26 28 1 11
第5注 1 5 19 21 26 8 11
emmm,我一会儿去买下看看能不能中……滑稽.jpg
八、结语
好的,这次的分享就到这里了,应该没什么遗漏的吧?因为最近事情比较多,所以这篇文章和相关代码编写的时间跨度很长,内容也比较多,虽然我已经通读了几遍,但可能还是会漏下某些没发现的问题,请见谅。分享中涉及到的全部代码,都已经上传到了GitHub,戳这里 (https://github.com/AaronJny/lotto)。
还是要强调一下,这只是一个以技术研究为出发点的娱乐性质的小实验,所以请不要指望这个能帮你赚大钱(如果运气爆棚真的中了,那就当我没说……见面分一半?),能少赔点就不错啦。
最后,请珍惜钱财,远离彩票。小赌怡情,大赌伤身。知难而退,量力而行。