使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测

写在最前面

首先郑重声明,这个赚不了钱!赚不了钱!赚不了钱!重要的话说三遍!

纯粹出于兴趣和技术做了个小实验,指望这个赚钱不太可能鸭!emmm,但可能会让你赔钱赔的少一点?

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第1张图片

转载请注明出处:https://blog.csdn.net/aaronjny/article/details/103276212

前言

以前从没买过彩票,前几天一时兴起,随机买了几注,然后兴致勃勃地等开奖。中奖序列出来后,比对一下,握草?!

果然没中~

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第2张图片

然后我就在考虑机器选彩票的技术可行性。

首先,大乐透的中奖序列为35选5+12选2,每个球的选取是随机的,因此,想使用机器学习精准地预测出获奖序列是很难(或者说基本不可能)的。为什么这么说呢?我们可以类比于机器学习选股票。目前有很多机器学习应用在股票选择上的例子,并能够实现盈利。但机器学习选股和选彩票有几点显著差异:

  • 股票只需要预测涨幅(回归)或者简单预测涨跌(二分类),但大乐透中奖序列需要从 C 35 5 ∗ C 12 2 C_{35}^5*C_{12}^2 C355C122种(我不是资深彩民,就是兴致一起随手买了两注,所以对各种彩票的规则了解不是很清楚,不清楚是否还有隐含规则,这个是根据字面意思计算得来的)组合中预测出一种,难度完全不是一个量级的
  • 股票的涨跌存在各种因子、k线、舆情等参数用于评估和训练,但彩票的中奖序列是随机产生的,可供参考的可能仅有时间上的序列的概率分布。

所谓的概率分布指的是,假设彩票的中奖序列是完全随机产生的话,序列中每一个球在一次开奖过程中出现的概率应该是相同的(前区和后区要分开算),并且从时间序列上来看,连续的很多次开奖中,每一个球的出现与否也应当满足某种规律(当然,这是宏观上讲,实际上肯定不会严格满足,但能够体现某种趋势或倾向)。

而我们又不打算靠这个中大奖,目标不是预测出头等奖的开奖序列,而是尽可能多的预测出可能会出现在中奖序列中的球。听上去好像没什么区别,但在模型设计上能够体现出差异——如果是前者,我们的模型应该是一个分类器,从 C 35 5 ∗ C 12 2 C_{35}^5*C_{12}^2 C355C122种类别中预测出其中一个,但考虑数据集规模和概率问题,根本不现实;而后者,我们则设计一个多输入、多输出的模型,输入是七个球的时间序列,输出是每个位置出现某个球的概率,这样就靠谱的多~emmm,我们不求赚钱,少输点就行~!

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第3张图片

行嘛,想了想好像没什么问题,那就开搞?于是,就有了这篇文章。

一、获取数据

想训练个模型的话,第一步肯定是获取数据啦。

我在网上找了一下,很快从[江苏体彩网](https://www.js-lottery.com/Pla 
yZone/lottoData.html)找到了历史开奖记录,可以直接下载,文件格式为csv。

这就很舒服啦,不用写爬虫到处一点点爬了。把文件下载下来看一下,数据是以这种形式组织的:

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第4张图片

很明显,最前面是期号,然后是前区的五个号码,最后是后区的两个号码。

ok,下面让我们来写一个下载数据集的脚本,以便于我们对数据进行更新:

# -*- coding: utf-8 -*-
# @File  : update_data.py
# @Author: AaronJny
# @Date  : 2019/10/29
# @Desc  :
import requests
import settings

print('开始尝试从 {} 获取最新的大乐透数据...'.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
}

我们尝试运行一下,看看效果:

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第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 settings


class 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,若干组开奖记录
        """
        # 如果没有指定路径,就是用初始化实例时传递的path
        if 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 lines

    def 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
            # 这样便于后面做softmax
            x_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形式也为list
                if 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]] = 1
                y[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_y
            for j in range(x_len):
                for k, num in enumerate(x[i][j]):
                    tmp_x[j][k][num] = 1
                for 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_x
            np_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_x
        self.train_np_y = train_np_y
        self.test_np_x = test_np_x
        self.test_np_y = test_np_y

    @property
    def 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
            # 这样便于后面做softmax
            x_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_y
            for 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_x
        return np_x

需要提一下的是,虽然每个球上的编号都是数字,但我们不应该把它们当数字,因为它们的大小关系在开奖中没有任何意义,且在训练中可能会对模型产生干扰。因此我们选择使用one-hot形式以类别的方式来表示它们,而不是一个数值。

同时,为了softmax和one-hot方便,我将球上的数字都减了一,把1-35变成0-34,1-12变成0-11。

三、编写模型

先给出模型示意图,再细说模型,可能会更清楚一些:

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第6张图片

前面提到“和预测气温差不多,我们使用连续若干期的球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 settings


def 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) / temperature
    exp_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:
    """
    # 前后区都没有命中,奖金为0
    if 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 = 0
        if 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 award


def 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 award


def select_seqs(predicts):
    """
    根据给定的概率分布,随机选择一种彩票序列
    :param predicts:list[list] 每一个球的概率分布组成的列表
    :return: list 彩票序列
    """
    balls = []
    # 对于每一种球
    for predict in predicts:
        try_cnt = 0
        while 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 balls


def 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 utils


def 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 += 10
        for k in range(5):
            # 存放每个球的概率分布的list
            probabilities = []
            # 对于每一种球,将其概率分布加入到列表中去
            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 += award
            if 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]

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第7张图片

这算是一个比较符合预期的结果?虽然还是在赔钱,但达到了我们的目的——少赔点钱=。=虽然一直在赔钱,但随着训练次数的增加,亏损金额在整体趋势上逐步减少。

[-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]

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第8张图片

这个是比较容易血压升高的结果?有一次回测的过程中中了80万…因为80万跟其他回测结果差距太大了,所以图像上基本显示不出其他轮次的起伏了。我们把最高的结果减去79万,看一下其他轮次的趋势:

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第9张图片

这个看起来就顺眼多了。

多次运行的结果可能差距明显,其原因分析如下:

  • 训练数据的原因。前面已经说过了,彩票选号其实是没有严格的规律可言的,否则,哪怕只有极少数一批人能稳定猜中,这个游戏也没法玩啊。如果非要强行说个规律出来,那也只有长期下来的概率分布能勉强凑合。但一来大乐透也只开了一千多期,数据有限,二来,概率这种东西从字面上来看,就知道它不是固定的(哪怕出现的概率最高,也不一定会出现)。这样,当模型的随机初始权重不同,训练数据又很难找到特别清晰的规律时,模型学习到的东西也会产生相应的区别,它们分别倾向到了概率分布的不同表现形式。
  • 回测时选择彩票号码的原因。选择号码时,同样不是一定选择出现概率最大的球,只是出现概率越大,被选中的概率就越大,这样保证了结果的多样性。

两者综合起来,两次的运行结果可能天差地别。但从多次运行的整体来看,还是有一定规律的:

  • 训练一定次数之后,亏损金额大多分布在[-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 settings


def 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 += 10
        for k in range(5):
            balls = get_one_random_sample()
            # 计算奖金
            award = utils.lotto_calculate(outputs, balls)
            money_in += award
    print('买彩票花费金钱共{}元,中奖金额共{}元,赚取{}元'.format(money_out, money_in, money_in - money_out))
    return money_in - money_out


dataset = 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))

运行一下,输出的结果是这样的:

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第10张图片

多次运行可以发现,最后的平均值绝大多数落在[-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)。

还是要强调一下,这只是一个以技术研究为出发点的娱乐性质的小实验,所以请不要指望这个能帮你赚大钱(如果运气爆棚真的中了,那就当我没说……见面分一半?),能少赔点就不错啦。

最后,请珍惜钱财,远离彩票。小赌怡情,大赌伤身。知难而退,量力而行。

使用Keras编写神经网络预测大乐透彩票,并利用历史数据回测_第11张图片

你可能感兴趣的:(机器学习)