先从 sklearn 说起吧, 如果学习了 sklearn 的话, 那么学习 Keras 相对来说比较容易. 为什么这样说呢?
我们首先比较一下 sklearn 的机器学习大致使用流程和 Keras 的大致使用流程:
sklearn 的机器学习使用流程:
from sklearn. 模型簇 import 模型名
from sklearn.metrics import 评价指标
'''数据预处理及训练测试集分离提取'''
myModel = 模型名称() # 对象初始化
myModel.fit(训练集 x , 训练集 y) #模型训练
y 预测集 = myModel.predict(开发集 x) #模型预测
评价指标 = 评价指标(y 预测集, y 测试集) #模型效果评估
Keras 的机器学习使用流程:
import keras
... 根据具体需求引入 keras 的包...
...keras 模型搭建...
...keras 模型编译(可选择模型指标)...
kerasModel.fit(训练集 x, 训练集 y)#keras 模型训练
y 预测集 = myModel.predict(开发集 x)#keras 模型预测
两者的区别
由上面伪代码可知 Keras 和 sklearn 最大不同在于需要进行模型搭建, 可是既然有了这么多模型为什么还要模型搭建?
如果你了解过神经网络感知机就会比较理解这个过程, 一个感知器相当于一个神经元, 可根据输入信息反馈出需要的电信号, 根据我们的世界观, 一个细胞可以单独执行很多功能但是大量单纯的任务会让细胞只针对一个方向发展. 用生物学的说话就是分化能力逐渐减弱, 机器学习说法就是过拟合. 因此, 只有大量细胞通过不同的组合才能完成纷繁复杂的预测任务, 因而有证明说神经网络理论上可拟合出任何曲线.
那么话说回来, Keras 需要自行搭建模型, 搭建方法有两种: 序贯模型和函数式模型. 而我本次的笔记就是学习序贯模型和函数式模型.
序贯模型
序贯模型是多个网络蹭的线性堆叠, 是函数式模型的简略版, 为最简单的线性, 从头到尾的结构顺序, 不发生分叉. 是多个网络层的线性堆叠.
Keras 实现了很多层, 包括 core 核心层, Convolution 卷积层, Pooling 池化层等非常丰富有趣的网络结构.
应用序贯模型的基本步骤
1,model.add() 添加层
2,model.compile() 模型训练的 BP 模式设置
3,model.fit() 模型训练参数设置 + 训练
4,model.evaluate() 模型评估
5,model.predict() 模型预测
序贯模型的创建
1, 可以通过向 Sequential 模型传递一个 layer 的 list 来构造 Sequential 模型:
- from keras.models import Sequential
- from keras.layers import Dense ,Activation
- model = Sequential([
- Dense(32,input_shape=(784,)),
- Activation('relu'),
- Dense(10),
- Activation('softmax')
- ])
- model.summary()
2, 也可以通过. add()方法一个个的将 layer 加入模型中:
- from keras.models import Sequential
- from keras.layers import Dense ,Activation
- model = Sequential()
- model.add(Dense(32,input_shape=(784,)))
- model.add(Activation('relu'))
- model.add(Dense(10))
- model.add(Activation('softmax'))
- model.summary()
结果如下:
- Using TensorFlow backend.
- _________________________________________________________________
- Layer (type) Output Shape Param #
- =================================================================
- dense_1 (Dense) (None, 32) 25120
- _________________________________________________________________
- activation_1 (Activation) (None, 32) 0
- _________________________________________________________________
- dense_2 (Dense) (None, 10) 330
- _________________________________________________________________
- activation_2 (Activation) (None, 10) 0
- =================================================================
- Total params: 25,450
- Trainable params: 25,450
- Non-trainable params: 0
- _________________________________________________________________
3, 指定输入数据的 shape
模型需要知道输入数据的 shape, 因此, Sequential 的第一层需要接受一个关于输入数据 shape 的参数, 后面的各个层则可以自动的推导出中间数据的 shape, 因此不需要为每一层都指定这个参数. 有几种方法来为第一层指定输入数据的 shape
1, 传递一个 input_shape 的关键字参数给第一层, input_shape 是一个 tupel 类型的数据 (一个整数或者 None 的元祖, 其中 None 表示可能为任何正整数) 在 input_shape 中不包含数据的 batch 大小.
- model = Sequential()
- model.add(Dense(64,input_shape=(20,),activation='relu'))
2, 有些 2D 层, 如 Dense, 支持通过指定其输入维度 input_dim 来隐含的指定输入数据 shape, 是一个 Int 类型的数据. 一些 3D 的时域层支持通过参数 input_dim 和 input_length 来指定输入 shape.
- model = Sequential()
- model.add(Dense(64, input_dim=20, activation='relu'))
3, 如果你需要为你的输入指定一个固定的 batch 大小 (这对于 statsful RNNs 很有用), 你可以传递一个 batch_size 参数给一个层. 如果你同时将 batch_size=32 和 input_shape = (6,8) 传递给一个层, 那么每一批输入的尺寸就是(32,6,8).
因此下面的代码是等价的:
- model = Sequential()
- model.add(Dense(32, input_shape=(784,)))
- model = Sequential()
- model.add(Dense(32, input_dim=784))
下面三种方法也是严格等价的
- model = Sequential()
- model.add(LSTM(32, input_shape=(10, 64)))
- model = Sequential()
- model.add(LSTM(32, batch_input_shape=(None, 10, 64)))
- model = Sequential()
- model.add(LSTM(32, input_length=10, input_dim=64))
4, 编译
在训练模型之前, 我们需要通过 compile 来对学习过程进行配置, compile 接收三个参数: 优化器 optimizer, 损失函数 loss, 指标列表 metrics.
compile(self, optimizer, loss, metrics=None, sample_weight_mode=None)
其中:
optimizer: 字符串 (预定义优化器名) 或者优化器对象,, 如 rmsprop 或 adagrad, 也可以是 Optimizer 类的实例. 详见: https://keras.io/zh/optimizers .
loss: 字符串 (预定义损失函数名) 或目标函数, 模型试图最小化的目标函数, 它可以是现有损失函数的字符串标识符, 如 categorical_crossentropy 或 mse, 也可以是一个目标函数. 详见: https://keras.io/zh/losses .
metrics: 列表, 包含评估模型在训练和测试时的网络性能的指标, 典型用法是 metrics=['accuracy']. 评估标准可以是现有的标准的字符串标识符, 也可以是自定义的评估标准函数.
注意:
模型在使用前必须编译, 否则在调用 fit 或者 evaluate 时会抛出异常.
例子:
- # 多分类问题
- model.compile(optimizer='rmsprop',
- loss='categorical_crossentropy',
- metrics=['accuracy'])
- # 二分类问题
- model.compile(optimizer='rmsprop',
- loss='binary_crossentropy',
- metrics=['accuracy'])
- # 均方误差回归问题
- model.compile(optimizer='rmsprop',
- loss='mse')
- # 自定义评估标准函数
- import keras.backend as K
- def mean_pred(y_true, y_pred):
- return K.mean(y_pred)
- model.compile(optimizer='rmsprop',
- loss='binary_crossentropy',
- metrics=['accuracy', mean_pred])
5, 训练
Keras 模型在输入数据和标签的 Numpy 矩阵上进行训练. 为了训练一个模型, 你通常会使用 fit 函数. 文档详见此处 https://keras.io/zh/models/sequential .
- fit(self, x, y, batch_size=32, epochs=10, verbose=1, callbacks=None,
- validation_split=0.0, validation_data=None, shuffle=True,
- class_weight=None, sample_weight=None, initial_epoch=0)
本函数将模型训练 nb_epoch 轮, 其参数有:
x: 输入数据, 如果模型只有一个输入, 那么 x 的类型是 numpy array, 如果模型有多个输入, 那么 x 的类型应当是 list,list 的元素是对应于各个输入的 numpy array
y: 标签 ,numpy array
batch_size: 整数, 指定进行梯度下降时每个 batch 包含的样本数, 训练时一个 batch 的样本会被计算一次梯度下降, 使目标函数优化一步.
epochs: 整数, 训练的轮数, 每个 epoch 会把训练集轮一遍.
verbose: 日志显示, 0 为不在标准输出流输出日志信息, 1 为输出进度条记录, 2 为每个 epoch 输出一行记录
callbacks:list,, 其中的元素是 keras.callbacks.Callback 的对象. 这个 list 中的回调函数将会在训练过程中的适当时机被调用, 参考回调函数.
validation_split:0~1 之间的浮点数, 用来指定训练集的一定比例数据作为验证集. 验证集将不参与训练, 并在每个 epoch 结束后测试的模型的指标, 如损失函数, 精确度等. 注意, validation_split 的划分在 shuffle 之前, 因此如果你的数据本身是有序的, 需要先手工打乱再指定 validation_split, 否则可能会出现验证集样本不均匀.
validation_data: 形式为 (X,y) 的 tuple, 是指定的验证集, 此参数将覆盖 validation_spilt.
shuffle: 布尔值或者字符串, 一般为布尔值, 表示是否在训练过程中随机打乱输入样本的顺序. 若为字符串 "batch", 则用来处理 HDF5 数据大特殊情况, 它将在 batch 内部将数据打乱.
class_weight: 字典, 将不同的类别映射为不同的权重, 该参数用来训练过程中调整损失函数(只能用于训练)
sample_weight: 权值的 numpy array, 用于在训练时调整损失(仅用于训练).
可以传递一个 1D 的与样本等长的向量用于对样本进行 1 对 1 的加权, 或者在面对时序数据时, 传递一个的形式为 (samples,sequence_length) 的矩阵来为每个时间步上的样本赋不同的权. 这种情况下请确定在编译模型时添加了 sample_weight_mode='temporal'.
initial_epoch: 从该参数指定的 epoch 开始训练, 在继续之前的训练时候有用.
fit 函数返回一个 History 的对象, 其 History.history 属性记录了损失函数和其他指标的数值随着 epoch 变化的情况, 如果有验证集的话, 也包含了验证集的这些指标变化情况.
示例一:
- # 对于具有 2 个类的单输入模型(二进制分类):
- model = Sequential()
- model.add(Dense(32, activation='relu', input_dim=100))
- model.add(Dense(1, activation='sigmoid'))
- model.compile(optimizer='rmsprop',
- loss='binary_crossentropy',
- metrics=['accuracy'])
- # 生成虚拟数据
- import numpy as np
- data = np.random.random((1000, 100))
- labels = np.random.randint(2, size=(1000, 1))
- # 训练模型, 以 32 个样本为一个 batch 进行迭代
- model.fit(data, labels, epochs=10, batch_size=32)
示例二:
- # 对于具有 10 个类的单输入模型(多分类分类):
- model = Sequential()
- model.add(Dense(32, activation='relu', input_dim=100))
- model.add(Dense(10, activation='softmax'))
- model.compile(optimizer='rmsprop',
- loss='categorical_crossentropy',
- metrics=['accuracy'])
- # 生成虚拟数据
- import numpy as np
- data = np.random.random((1000, 100))
- labels = np.random.randint(10, size=(1000, 1))
- # 将标签转换为分类的 one-hot 编码
- one_hot_labels = keras.utils.to_categorical(labels, num_classes=10)
- # 训练模型, 以 32 个样本为一个 batch 进行迭代
- model.fit(data, one_hot_labels, epochs=10, batch_size=32)
6, 评估
根据验证集评估模型的好坏
evaluate(self, x, y, batch_size=32, verbose=1, sample_weight=None)
本函数按 batch 计算在某些输入数据上模型的误差, 其参数有:
x: 输入数据, 与 fit 一样, 是 numpy array 或者 numpy array 的 list
y: 标签, numpy array
batch_size: 整数, 含义同 fit 的同名参数
verbose: 含义同 fit 的同名参数, 但是只能取 0 或 1
sample_weight:numpy array , 含义同 fit 的同名参数
本函数返回一个测试误差的标量值(如果模型没有其他评价指标), 或一个标量的 list(如果模型还有其他的评价指标).model.metrics_names 将给出 list 中各个值的含义.
如果没有特殊说明, 以下函数的参数均保持与 fit 的同名参数相同的含义
如果没有特殊说明, 以下函数的 verbose 参数 (如果有) 均只能取 0 或者 1
- score = model.evaluate(x_val , y_val ,batch_size = 128)
- print('val score:', score[0])
- print('val accuracy:', score[1])
7, 预测
对已经训练完成的模型, 输入特征值 x 会预测得到标签 y.
- predict(self, x, batch_size=32, verbose=0)
- predict_classes(self, x, batch_size=32, verbose=1)
- predict_proba(self, x, batch_size=32, verbose=1)
本函数按 batch 获得输入数据对应的输出, 其参数有:
函数的返回值是预测值的 numpy array
predict_classes: 本函数按 batch 产生输入数据的类别预测结果
predict_proba: 本函数按 batch 产生输入数据属于各个类别的概率
- x = 1
- y = model.predict(x,verbose=0)
- print(y)
8,on_batch,batch 的结果, 检查
- train_on_batch(self, x, y, class_weight=None, sample_weight=None)
- test_on_batch(self, x, y, sample_weight=None)
- predict_on_batch(self, x)
train_on_batch: 本函数在 batch 的数据上进行一次参数更新, 函数返回训练误差的标量值或者标量值的 list, 与 evaluate 的情形相同.
test_on_batch: 本函数在一个 batch 的样本上对模型进行评估, 函数的返回与 evaluate 的情形相同.
predict_on_batch: 本函数在一个 batch 的样本上对模型进行测试, 函数返回模型在一个 batch 上的预测结果.
9, 完整代码及其一次结果
代码:
- from keras.models import Sequential
- from keras.layers import Dense ,Activation,Dropout
- import numpy as np
- # 准备训练集和验证集
- x_train = np.random.random((1000,20))
- y_train = np.random.randint(2,size=(1000,1))
- x_val = np.random.random((100,20))
- y_val = np.random.randint(2,size=(100,1))
- model = Sequential()
- model.add(Dense(64,input_dim=20,activation='relu'))
- model.add(Dropout(0.5))
- model.add(Dense(64,activation='relu'))
- model.add(Dropout(0.5))
- model.add(Dense(1,activation='sigmoid'))
- model.compile(loss='binary_crossentropy',optimizer='rmsprop',metrics=['accuracy'])
- model.fit(x_train,y_train,epochs=20,batch_size=128)
- score = model.evaluate(x_val , y_val ,batch_size = 128)
- print('val score:', score[0])
- print('val accuracy:', score[1])
- # x = 1
- # y = model.predict(x,verbose=0)
- # print(y)
结果:
- Using TensorFlow backend.
- Epoch 1/20
- 128/1000 [==>...........................] - ETA: 1s - loss: 0.7093 - acc: 0.5469
- 1000/1000 [==============================] - 0s 291us/step - loss: 0.7098 - acc: 0.5090
- Epoch 2/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.7191 - acc: 0.4766
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.7087 - acc: 0.5080
- Epoch 3/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.7009 - acc: 0.4766
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.6969 - acc: 0.5040
- Epoch 4/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6946 - acc: 0.5312
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.6969 - acc: 0.5240
- Epoch 5/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.7029 - acc: 0.4609
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.7002 - acc: 0.4950
- Epoch 6/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.7027 - acc: 0.4531
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.6992 - acc: 0.5090
- Epoch 7/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6907 - acc: 0.5312
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6895 - acc: 0.5290
- Epoch 8/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6906 - acc: 0.5000
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6969 - acc: 0.5040
- Epoch 9/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6860 - acc: 0.5078
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6914 - acc: 0.5280
- Epoch 10/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6784 - acc: 0.6016
- 1000/1000 [==============================] - 0s 17us/step - loss: 0.6912 - acc: 0.5390
- Epoch 11/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6812 - acc: 0.6406
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6874 - acc: 0.5330
- Epoch 12/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6997 - acc: 0.4766
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6949 - acc: 0.5080
- Epoch 13/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6843 - acc: 0.5781
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.6912 - acc: 0.5380
- Epoch 14/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6746 - acc: 0.5703
- 1000/1000 [==============================] - 0s 17us/step - loss: 0.6873 - acc: 0.5360
- Epoch 15/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6959 - acc: 0.5000
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6891 - acc: 0.5310
- Epoch 16/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6780 - acc: 0.5938
- 1000/1000 [==============================] - 0s 17us/step - loss: 0.6907 - acc: 0.5280
- Epoch 17/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6835 - acc: 0.5938
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6858 - acc: 0.5690
- Epoch 18/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6845 - acc: 0.4922
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6921 - acc: 0.5220
- Epoch 19/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6861 - acc: 0.5625
- 1000/1000 [==============================] - 0s 15us/step - loss: 0.6859 - acc: 0.5540
- Epoch 20/20
- 128/1000 [==>...........................] - ETA: 0s - loss: 0.6959 - acc: 0.5312
- 1000/1000 [==============================] - 0s 16us/step - loss: 0.6841 - acc: 0.5530
- 100/100 [==============================] - 0s 440us/step
- val score: 0.6957631707191467
- val accuracy: 0.5099999904632568
函数式模型
比序贯模型要复杂, 但是效果很好, 可以同时 / 分阶段输入变量, 分阶段输出想要的模型.
所以说, 只要你的模型不是类似 VGG 一样
1, 应用函数式模型的基本步骤
1,model.layers() 添加层
2,model.compile() 模型训练的 BP 模式设置
3,model.fit() 模型训练参数设置 + 训练
4,evaluate() 模型评估
5,predict() 模型预测
2, 常用 Model 属性
model.layers: 组成模型图的各个层
model.inputs: 模型的输入张量列表
model.outputs: 模型的输出张量列表
model = Model(inputs=, outputs = )
3, 指定输入数据的 shape
inputs = Input(shape = (20, ))
4, 编译, 训练, 评估, 预测等步骤与序贯式模型相同(这里不再赘述)
compile(self, optimizer, loss, metrics=None, loss_weights=None, sample_weight_mode=None)
本函数按 batch 计算在某些输入数据上模型的误差, 其参数有:
x: 输入数据, 与 fit 一样, 是 numpy array 或者 numpy array 的 list
y: 标签, numpy array
batch_size: 整数, 含义同 fit 的同名函数
verbose: 含义与 fit 的同名函数, 但是只能取 0 或者 1
sample_weight:numpy array, 含义同 fit 的同名函数
本函数编译模型以供训练, 参数有:
evaluate(self, x, y, batch_size=32, verbose=1, sample_weight=None)
5, 示例一(基于上面序贯模型进行改造)
- import numpy as np
- from keras.models import Model
- from keras.layers import Dense , Dropout,Input
- # 准备训练集和验证集
- x_train = np.random.random((1000,20))
- y_train = np.random.randint(2,size=(1000,1))
- x_val = np.random.random((100,20))
- y_val = np.random.randint(2,size=(100,1))
- inputs = Input(shape = (20,))
- x = Dense(64,activation='relu')(inputs)
- x = Dropout(0.5)(x)
- x = Dense(64,activation='relu')(x)
- x = Dropout(0.5)(x)
- predictions = Dense(1,activation='sigmoid')(x)
- model=Model(inputs=inputs, outputs=predictions)
- model.compile(loss='binary_crossentropy', optimizer='rmsprop', metrics=['accuracy'])
- model.fit(x_train, y_train,epochs=20,batch_size=128)
- score = model.evaluate(x_val, y_val, batch_size=128)
- print('val score:', score[0])
- print('val accuracy:', score[1])
序贯模型和函数模型共同的 API
model.summary(): 打印出模型的概况, 它实际调用的是 keras.utils.print_summary
model.get_config(): 返回包含模型配置信息的 Python 字典, 模型也可以从 config 中重构回去.
- config = model.get_config()
- model = Model.from_config(config)
- model = Sequential.from_config(config)
上面是分别对序贯模型和函数式模型载入 config
model.get_layer(): 依据层名或下标获得层对象
model.get_weights(): 返回模型权重张量的列表, 类型为 numpy.array
model.set_weights(): 从 numpy array 里载入给模型, 要求数组与 model.get_weights()一样
model.to_json(): 返回代表模型的 JSON 字符串, 仅仅包含网络结构, 不包含权重, 可以从 JSON 字符串中重构模型
Keras 模型保存
首先不推荐使用 pickle 或者 cPickle 来保存 Keras 模型
1, 保存结构
可以使用 model.save(filepath)将 Keras 模型和权重保存在一个 HDF5 文件中, 该文件将包含:
模型的结构, 以便重构该模型
模型的权重
训练配置(损失函数, 优化器等)
优化器的状态, 以便从上次训练中断的地方开始
使用 Keras.models.load_model(filepath)来重新实例化你的模型, 如果文件中存储了训练配置的话, 该函数还会同时完成模型的编译. 例如:
- from keras.models import load_model
- # create a HDF5 file 'my_load.h5'
- model.save('my_load.h5')
- # deletes the existing model
- del model
- # returns a compiled model
- # indentical to the previous one
- model = load_model('my_load.h5')
2, 保存结构
如果你只是希望保存模型的结构, 而不包括其权重或者配置信息, 可以使用:
- # save as JSON
- json_string = model.to_json()
- # save as YAML
- yaml_string = model.to_yaml()
这项操作可以将模型序列化为 JSON 或者 YAML 文件, 如果需要的话你甚至可以手动打开这些文件进行编辑.
当然你也可以从保存好的 JSON 文件或者 YAML 文件中载入模型:
- # model reconstruction from JSON:
- from keras.models import model_from_json
- model = model_from_json(json_string)
- # model reconstruction from YAML
- model = model_from_yaml(yaml_string)
3, 保存模型的权重
如果需要保存模型, 可通过下面的代码利用 HDF5 进行保存. 注意, 在使用前需要确保你已经安装了 HDF5 和 Python 的库 h5py.
model.save_weights('my_model_weights.h5')
如果你需要在代码中初始化一个完全相同的模型, 请使用:
model.load_weights('my_model_weights.h5')
4, 加载权重到不同的网络结构
如果你需要加载权重到不同的网络结构 (有些层一样) 中, 例如 fine-tune 或 transfer-learning, 你可以通过层名字来加载模型.
model.load_weights('my_model_weights.h5', by_name=True)
例如:
- """
- 假如原模型为:
- model = Sequential()
- model.add(Dense(2, input_dim=3, name="dense_1"))
- model.add(Dense(3, name="dense_2"))
- ...
- model.save_weights(fname)
- """
- # new model
- model = Sequential()
- model.add(Dense(2, input_dim=3, name="dense_1")) # will be loaded
- model.add(Dense(10, name="new_dense")) # will not be loaded
- # load weights from first model; will only affect the first layer, dense_1.
- model.load_weights(fname, by_name=True)
加载权重到不同的网络结构上多数用于迁移学习.
参考文献: https://zhuanlan.zhihu.com/p/37376691
https://zhuanlan.zhihu.com/p/50543770
来源: https://www.cnblogs.com/wj-1314/p/9967480.html