运用计算图搭建 LR、FM 等常见机器学习模型

文章作者:张觉非 360

编辑整理:Hoh Xil

内容来源:作者授权

出品社区:DataFun

注:欢迎转载,转载请注明出处

上一篇 文章 中,我们用 Python + Numpy 写了一个简单的计算图框架(我准备将它命名为  VectorSlow ),它支持以下几种计算节点:

Add   节点接受两个同维的向量节点作为父节点,对它们计算向量加法:

Dot   节点接受两个同维的向量节点作为父节点,对它们计算向量内积:

Logistic   节点接受一个向量节点作为父节点,对它的每个分量施加 Logistic 函数。

ReLU   节点接受一个向量节点作为父节点,对它的每个分量施加 ReLU 函数。

SoftMax   节点接受一个向量节点作为父节点,对它的分量施加 SoftMax 函数。

CrossEntropyWithSoftMax   节点接受两个同维的向量节点作为父节点,对第一个父节点施加 SoftMax 后,与第二个父节点(存储类别 One-Hot 编码)计算交叉熵。

Variable   节点保存变(向)量,它没有父节点,它的值可以被赋与或随机初始化,可指定它是否参与训练。

Vectorize   节点接受 k 个 1 维向量(即标量)父节点,将它们组合成一个 k 维向量。

只用这几种节点,我们就可以搭建相当一部分机器学习模型了。例如逻辑回归模型,它的计算是用权值向量与输入向量做内积,再加上偏置值(标量),最后施加 Logistic 函数:

用我们的计算图可以这幺表达:

from node import Variable, Dot, Add, Logistic

# 输入维度,即特征个数
n = 10
# x 是一个 n 维向量变量,不初始化,不参与训练,相当于 TensorFlow 中的 PlaceHolder
x = Variable(n, init=False, trainable=False)
# w 是一个 n 维向量变量,随机初始化,参与训练,相当于 TensorFlow 中的 Variable
w = Variable(n, init=True, trainable=True)
# b 是一个 1 维向量(标量)变量,随机初始化,参与训练
b = Variable(1, init=True, trainable=True)
# 求 w 和 x 的内积,加上偏置,施加 Logistic 函数
y = Logistic(Add(Dot(w, x), b))

上述代码就构造了一个逻辑回归模型的计算图。有的模型需要计算矩阵与向量之积,例如神经网络的全连接层,它的计算可以这幺表达:

式中 是 的权值矩阵,是维输入向量, 是 维偏置向量。用 乘 后加上 ,得到一个 维向量,对这个 维向量的每一个分量施加 Logistic 函数,得到 维输出向量 。激活函数也可以替换成 ReLU 。这就是一个全连接层的计算。若要计算矩阵与向量的乘积,可以计算矩阵的每一行与向量的内积,再将得到的多个内积组合成向量。可以用我们的计算图这样表达全连接层的计算:

from node import Variable, Dot, Add, Logistic, Vectorize
# 输入维度,即特征个数
n = 10
# 输出维度,即全连接层的神经元个数
k = 6
# x 是一个 n 维向量变量,不初始化,不参与训练
x = Variable(n, init=False, trainable=False)
# 数组,保存权值矩阵 W 的 k 个行,每一行是一个 n 维向量
W_rows = []
for i in range(k):
    # 每一行是一个 n 维向量变量,随机初始化,参与训练
    W_rows.append(Variable(n, init=True, trainable=True)))
# 数组,保存权值矩阵 W 的每一行与 x 的内积
Wx = []
for i in range(k):
    # 权值矩阵第 i 行与 x 的内积
    Wx.append(Dot(W_rows[i], x))
# 以 Wx 的全部元素为输入节点,构造一个 k 维向量,赋给 Wx
Wx = Vectorize(*Wx)
# b 是一个 k 维向量变量,包含 k 个偏置值,随机初始化,参与训练
b = Variable(k, init=True, trainable=True)
# y 是 k 维向量,是全连接层的输出
y = Logistic(Add(Wx, b))

我们的这个计算图框架的表达能力已经能够胜任许多模型了。最后说明一点,如果我们把模型最后的 SoftMax 层的输入记作 logits ,对 logits 施加 SoftMax 就是模型输出的多分类概率。

CrossEntropyWithSoftMax 节点接受两个输入向量,一个是 logits ,一个是 label 。label 包含多分类 One-Hot 编码向量。CrossEntropyWithSoftMax 先对 logits 向量施加 SoftMax ,再将得到的多分类概率与 label 计算交叉熵,即损失值。

# 模型输出的多分类概率
probability = SoftMax(logits)
# 交叉熵损失
loss = CrossEntropyWithSoftMax(logits)

在  util.py  中我们写了一个获取数据样本的函数 get_data ,它根据类别数生成多簇均值和协方差矩阵不同的多元正态分布数据。多元正态分布的“元”数是样本特征数。特征数、类别数、每类的样本数、训练集/测试集比例都通过参数指定,类别标签以 One-Hot 编码形式返回。get_data 返回四个数组:

train_x ,形状是“训练集样本数 x 特征数”,即训练集样本;

train_y ,形状是“训练集样本数 x 类别数”,即训练集类别 One-Hot 标签;

test_x , 形状是“测试集样本数 x 特征数”,即测试集样本;

test_y , 形状是“测试集样本数 x 类别数”,即测试集类别 One-Hot 标签;

import numpy as np

def get_data(number_of_classes=2, seed=42, number_of_features=5, number_of_examples=1000, train_set_ratio=0.7):
    np.random.seed(seed)
    # 对每一类别生成样本
    data = []
    for i in range(number_of_classes):
        h = np.mat(np.random.random((number_of_features, number_of_features))) * 0.2
        features = np.random.multivariate_normal(
            mean=np.random.random(number_of_features),
            cov=h.T * h + 0.03 * np.mat(np.eye(number_of_features)),  # 随机生成一个对称矩阵作为协方差矩阵,有可能不正定
            check_valid="raise",  # 万一不正定了,抛异常
            size=number_of_examples  # 样本数
        )
        labels = np.array([[int(i == j) for j in range(number_of_classes)]] * number_of_examples)
        data.append(np.c_[features, labels])
    # 把各个类别的样本合在一起
    data = np.concatenate(data, axis=0)
    # 随机打乱样本顺序
    np.random.shuffle(data)
    # 计算训练样本数量
    train_set_size = int(number_of_examples * train_set_ratio)  # 训练集样本数量
    # 将训练集和测试集、特征和标签分开
    return (data[:train_set_size, :-number_of_classes],
            data[:train_set_size, -number_of_classes:],
            data[train_set_size:, :-number_of_classes],
            data[train_set_size:, -number_of_classes:])

现在我们开始用计算图搭建各种模型。

LR

代码很简单,见  lr.py :

from node import *

def logistic_regression(n):
    """
    构造一个逻辑回归模型的计算图,特征数量为 n 。
    """
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # w 是一个 n 维向量变量,随机初始化,参与训练
    w = Variable(n, init=True, trainable=True)
    # b 是一个 1 维向量(标量)变量,随机初始化,参与训练
    b = Variable(1, init=True, trainable=True)
    # 求 w 和 x 的内积,加上偏置,施加 Logistic 函数
    logit = Add(Dot(w, x), b)
    # 返回输入和未施加 Logistic 的 logit ,至于为什幺不施加 Logistic ,看训练代码
    return x, logit

我们没有对逻辑回归的输出施加 Logistic,因为我们的计算图框架只有一种计算交叉熵损失的方式: CrossEntropyWithSoftMax 。它接受的是多分类(包含二分类)的 logits 。上述代码中的逻辑回归返回输入节点 x 与 logit 节点,训练时构造一个常数 0 节点当作另一个 logit ,这是因为:

上式输出两个概率,正类概率和负类概率。可见,正类概率正是对第一个 logit 施加 Logistic 函数的结果。逻辑回归模型的训练代码如下( train_models.py  ):

import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import accuracy_score, classification_report, confusion_matrix
from optimizer import *
from util import get_data, construct_pow2
algo = "ffm"
n = 5  # 特征数
classes = 2
epoches = 20
# 构造训练数据
train_x, train_y, test_x, test_y = get_data(number_of_classes=classes, number_of_features=n)
if algo == "lr":
    from lr import logistic_regression
    X, logit = logistic_regression(n)
    X_2 = None  # 简单逻辑回归模型没有二次特征
    # 由于我们只有 SoftMax 节点(包括后续损失的那个),对于二分类问题,我们也得要求
    # 模型的输出是 2 分类概率,所以我们构造一个常量 0 ,当作第 2 个 logit ,LR 模
    # 型输出的值作为第一个 logit ,构造 2 维 logit 向量。
    c = Variable(1, init=False, trainable=False)
    c.set_value(np.mat([[0]]))
    logits = Vectorize(logit, c)
elif algo == "neural network":
    from nn import neural_network
    X, logits = neural_network(n, classes, (6, 6))
    X_2 = None  # 多层全连接神经网络不用二次特征
elif algo == "wide & deep":
    from wide_and_deep import wide_and_deep
    X, X_2, logits = wide_and_deep(n, classes)
elif algo == "fm":
    from fm import factorization_machine
    X, X_2, logits = factorization_machine(n, classes)
elif algo == "ffm":
    from ffm import field_aware_factorization_machine
    fields = [(0, 1, 3), (2, 4)]
    X, X_2, logits = field_aware_factorization_machine(n, fields, classes, latent_dim=7)
elif algo == "deepfm":
    from deepfm import deepfm
    X, X_2, logits = deepfm(n, classes, latent_dim=6)
# 绘制计算图(不包括损失)
default_graph.draw()
# 对模型输出的 logits 施加 SoftMax 得到多分类概率
prob = SoftMax(logits)
# 训练标签
label = Variable(classes, trainable=False)
# 交叉熵损失
loss = CrossEntropyWithSoftMax(logits, label)  # 注意第一个父节点是 logits
# Adam 优化器
optimizer = Adam(default_graph, loss, 0.02, batch_size=32)
# 训练
for e in range(epoches):
    # 每个 epoch 开始时在测试集上评估模型正确率
    probs = []
    losses = []
    for i in range(len(test_x)):
        X.set_value(np.mat(test_x[i, :]).T)
        if X_2 is not None:
            X_2.set_value(construct_pow2(np.mat(test_x[i, :]).T))
        label.set_value(np.mat(test_y[i, :]).T)
        # 前向传播计算概率
        prob.forward()
        probs.append(prob.value.A1)
        # 计算损失值
        loss.forward()
        losses.append(loss.value[0, 0])
    # 取概率最大的类别为预测类别
    pred = np.argmax(np.array(probs), axis=1)
    truth = np.argmax(test_y, axis=1)
    accuracy = accuracy_score(truth, pred)
    print("Epoch: {:d},损失值:{:.3f},正确率:{:.2f}%".format(e + 1, np.mean(losses), accuracy * 100))
    for i in range(len(train_x)):
        X.set_value(np.mat(train_x[i, :]).T)
        if X_2 is not None:
            X_2.set_value(construct_pow2(np.mat(train_x[i, :]).T))
        label.set_value(np.mat(train_y[i, :]).T)
        # 执行一步优化
        optimizer.one_step()
        # print("Epoch: {:d},Iteration:{:d}".format(e + 1, i + 1))
# 训练结束后打印最终评价
print("\n测试集最终正确率:{:.3f}".format(accuracy_score(truth, pred)))
print(classification_report(truth, pred))
fig = plt.figure(figsize=(8, 8))
ax = fig.add_subplot(111)
_ = sns.heatmap(
    confusion_matrix(truth, pred),
    square=True,
    annot=True,
    annot_kws={"fontsize": 8},
    cbar=False,
    cmap=sns.light_palette("#00304e", as_cmap=True),
    ax=ax
)

调用 logistic_regression 函数构造计算图,得到特征 X 变量和模型输出的 logit 变量。将 logit 和一个不可训练的变量(1 维向量,值为 0)一起组成两路 logits 向量,对 logits 施加 SoftMax 得到模型的输出概率。label 变量存储训练样本的类别 One-Hot 编码向量。将 logits 和 label 一起送给 CrossEntropyWithSoftMax 构造交叉熵损失。构造一个 Adam 优化器并开始训练,每次迭代将训练样本的特征喂给 X ,将训练样本的类别 One-Hot 编码喂给 label ,调用优化器的 one_step 函数进行前向/反向传播。每个 epoch 开始时在测试集上计算分类正确率和损失值。运行该代码的输出为:

Epoch: 1,损失值:0.693,正确率:49.92%
Epoch: 2,损失值:0.582,正确率:78.38%
Epoch: 3,损失值:0.492,正确率:91.38%
Epoch: 4,损失值:0.434,正确率:91.92%
Epoch: 5,损失值:0.390,正确率:92.38%
Epoch: 6,损失值:0.355,正确率:93.08%
Epoch: 7,损失值:0.326,正确率:93.85%
Epoch: 8,损失值:0.302,正确率:94.31%
Epoch: 9,损失值:0.282,正确率:94.46%
Epoch: 10,损失值:0.265,正确率:94.77%
Epoch: 11,损失值:0.250,正确率:94.92%
Epoch: 12,损失值:0.236,正确率:95.00%
Epoch: 13,损失值:0.225,正确率:95.23%
Epoch: 14,损失值:0.214,正确率:95.31%
Epoch: 15,损失值:0.204,正确率:95.54%
Epoch: 16,损失值:0.195,正确率:95.69%
Epoch: 17,损失值:0.188,正确率:95.69%
Epoch: 18,损失值:0.181,正确率:95.62%
Epoch: 19,损失值:0.175,正确率:95.85%
Epoch: 20,损失值:0.169,正确率:95.85%
测试集最终正确率:0.958
              precision    recall  f1-score   support
           0       0.97      0.94      0.96       667
           1       0.94      0.97      0.96       633
    accuracy                           0.96      1300
   macro avg       0.96      0.96      0.96      1300
weighted avg       0.96      0.96      0.96      1300

将这个逻辑回归计算图绘制出来:

LR 的计算图

NN

NN 就是 Neural Network ,即多层全连接神经网络。相信本专栏的读者对多层全连接神经网络应该已经非常熟悉了,我们在往期文章 《神经网络反向传播算法》 中也实现过多层全连接神经网络的反向传播算法,但那是一种 Ad Hoc(特设的)的算法。我们说计算图自动求导是通用的、广义的反向传播。现在,我们就用我们的计算图框架搭建一个多层全连接神经网络,代码如下( nn.py ):

from node import *

def neural_network(n, classes=2, hiddens=(12,), activation="ReLU"):
    """
    构造一个多层全连接神经网络的计算图。
    """
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # 构造全连接层
    output_size = n
    output = x
    for h_size in hiddens:
        hidden = []
        for i in range(h_size):
            hidden.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
        hidden = Vectorize(*hidden)
        # 隐藏层的输出
        if activation == "ReLU":
            output = ReLU(hidden)
        elif activation == "Logistic":
            output = Logistic(hidden)
        else:
            output = hidden
        output_size = h_size
    # 输出层的神经元
    logits = []
    for i in range(classes):
        logits.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
    logits = Vectorize(*logits)
    # 返回输入和 logits
    return x, logits

训练代码中加入分支:

elif algo == "neural network":
    from nn import neural_network
    X, logits = neural_network(n, classes, (6, 6))
    X_2 = None  # 多层全连接神经网络不用二次特征

我们搭建的 NN 计算图是这样的:

NN 的计算图

Wide & Deep

Wide & Deep 是谷歌提出的一种将深度神经网络与传统逻辑回归模型相结合的模型,用于推荐系统和 CTR 预估等领域。在开始介绍之前我们需要提醒读者,由于作者乃一介中年失意民科,“耳聋听不见大神旨,眼花看不清论文章”,我只大致浏览过原始论文,自认为把握住了思想,所以我们在这里实现的模型可能与原始模型有所出入,请读者明察。

Wide & Deep 的 Wide 部分是一个逻辑回归,它的输入不是原始特征,而是人工构造的高次交互特征。比如原始特征中有 和 ,那幺可以构造二次交互项 作为一个新特征,它刻画 和 之间的交互作用。从数学上看,加入二次项后,线性模型其实是对原始特征的二次模型。实际建模工作中,应该由领域专家决定加入哪些高次交互项,这里我们简单地将所有特征的两两组合都加入进来,于是共有 个二次交互项,将它们作为模型 Wide 部分的输入。 Wide 部分根据类别数构造一个多分类逻辑回归,它的输出是 logits 向量 。

模型的 Deep 部分是一个多层全连接神经网络,以原始特征为输入,输出也是 logits 向量。原始论文将 Wide 部分和 Deep 部分的 logits 加权相加得到最终 logits ,权重可训练,我们去掉加权,只简单将两部分的 logits 相加。构造 Wide & Deep 计算图的代码如下( wide_and_deep.py ):

from node import *

def wide_and_deep(n, classes=2, hiddens=(12,)):
    """
    构造一个Wide & Deep模型的计算图,特征数量为 n 。 n 个特征能产生
    C(n, 2) 个二次交互项 xi * xj 。
    """
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # 二次特征,共C(n, 2)=n * (n-1) / 2个
    power2_n = int(n * (n - 1) / 2)
    x_2 = Variable(power2_n, init=False, trainable=False)
    # Wide部分
    wide = []
    for i in range(classes):
        wide.append(Add(Dot(Variable(power2_n, True), x_2), Variable(1, True)))
    wide = Vectorize(*wide)
    # Deep部分
    output_size = n
    output = x
    for h_size in hiddens:
        hidden = []
        for i in range(h_size):
            hidden.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
        # 隐藏层的输出
        output = ReLU(Vectorize(*hidden))
        output_size = h_size
    # 输出层的神经元
    deep = []
    for i in range(classes):
        deep.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
    deep = Vectorize(*deep)
    # 将Wide部分和Deep部分的输出相加,得到logits
    logits = Add(wide, deep)
    # 返回输入和logits
    return x, x_2, logits

Wide & Deep 需要二次交互项,为此我们在  util.py  中添加一个函数,用原始特征构造二次项:

def construct_pow2(x):
    """
    利用特征构造二次交互项特征
    """
    m = x * x.T
    x_2 = []
    for i in range(len(x)):
        for j in range(i):
            x_2.append(m[i, j])
    return np.mat(x_2).T

该函数用 维向量 乘上它的转置 得到一个 对称矩阵,该矩阵对角线上是 的各分量的平方,非对角线上是各分量的二次乘积项,例如第 元素和第 元素都是 。 取该矩阵对角线以下的元素,组成一个向量返回出来,这个向量包含所有原始特征的二次交互项。训练代码添加:

elif algo == "wide & deep":
    from wide_and_deep import wide_and_deep
    X, X_2, logits = wide_and_deep(n, classes)

以特征数 n 和问题的类别数 classes 调用 wide_and_deep 函数构造计算图,得到原始特征 X 、二次交互项 X_2以及模型输出的 logits 。训练部分的代码用 logits 构造交叉熵损失,将原始特征和二次特征喂给 X 和 X_2 。20 个 epoch 后,Wide & Deep 的测试集正确率是 96.2%,相比逻辑回归稍有进步。这份数据并不特别能体现 Wide & Deep 模型的优势,大家看个意思就得了。

Wide and Deep 的计算图

FM

接下来我们构造因子分解机( Factorization Machine,FM)。首先来看构造 FM 的计算图的代码( fm.py ):

from node import *

def factorization_machine(n, classes=2, latent_dim=6):
    """
    构造一个FM模型的计算图,特征数量为 n 。 n 个特征能产生
    C(n, 2) 个二次交互项 xi * xj 。
    """
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # 二次特征,共C(n, 2)=n * (n-1) / 2个
    power2_n = int(n * (n - 1) / 2)
    x_2 = Variable(power2_n, init=False, trainable=False)
    # 有多少类别,就有多少 logit
    logits = []
    for c in range(classes):
        # 一次部分,简单的将特征加权相加(求输入向量与权值向量的内积)
        order_1 = Dot(Variable(n, True), x)
        # 二次隐藏向量部分,首先为每个特征构造一个隐藏向量
        latent_vectors = []
        for i in range(n):
            latent_vectors.append(Variable(latent_dim, True))
        # 每个二次交互项的权重是两个原始特征的隐藏向量的内积
        latent_weights = []
        for i in range(n):
            for j in range(i):
                latent_weights.append(Dot(latent_vectors[i], latent_vectors[j]))
        # 全部二次交互项的加权和
        order_2 = Dot(Vectorize(*latent_weights), x_2)
        # 偏置
        bias = Variable(1, True)
        # 将一次部分、二次部分和偏置相加
        logits.append(Add(Add(order_1, order_2), bias))
    # 将多个 logit 组装成 logits 向量
    logits = Vectorize(*logits)
    # 返回输入和logits
    return x, x_2, logits

前面介绍 Wide & Deep 时提到,模型的 Wide 部分以原始特征的二次交互项为特征,例如 。在推荐系统或 CTR 预估以及其他一些建模场景中,特征很少是实数值,而通常是一个 1\0 标志,标志某件事情是否发生,例如用户是否购买/收藏过某件物品。特征还有可能是“类别”类型,例如用户所在的城市,若将类别类型的特征做 One-Hot 编码,则产生多个 1/0 特征,它们仍是标志,例如标志用户所在城市是否是北京。

大量 1/0 标志的二次交互项是稀疏的。假如一共有 1000 件商品,对于每一个样本(用户),特征 和 用 1/0 分别标识该用户是否购买过这两种商品,则只有当某位用户确实购买过这两件商品时,才有交互项 。当商品数量很多时,很有可能 在整个训练集上都不为 1 ,那幺 项的权值 就得不到训练。

为了克服这个问题, FM 模型为每个特征 配备一个向量 ,称为隐藏向量(latent vector),用隐藏向量的内积 作为二次交互项 的权重。 FM 的计算式可以写为:

一次项仍是一个普通的加权求和,二次项的权为相应一次特征的隐藏向量的内积。采用了隐藏向量,只要 在训练集样本中与任意其他 同时为 1 ,则 的隐藏向量 就会得到训练, 的 也是同样。所以即便 和 在训练集中从未同时为 1 (即 从不为 1), 的权重 仍能得到训练。

训练代码添加:

elif algo == "fm":
    from fm import factorization_machine
    X, X_2, logits = factorization_machine(n, classes)

FM 的计算图是这样的:

FM 的计算图

FFM

FFM 比 FM 多了一个“F”,它的全称是 Field-Aware Factorization Machine ——“域感知”的因子分解机。说来也简单,FFM 将原始特征聚合为若干个域(field),聚合的标准很灵活,例如可以将同一个原始类别型特征经过 One-Hot 编码出来的一组特征归为一个域,也可以根据原始特征在现实问题上的联系将它们归为不同的域。

FM 为每个原始特征配备一个隐藏向量,而 FFM 为每个原始特征配备多个隐藏向量,每一个对应一个域。例如,假如有 个特征,归为 个域,则共有 和隐藏向量。若特征 属于第 个域, 属于第 个域,那幺  项的权重就是第 个隐藏向量和第 个隐藏向量的内积,也就是说,每个特征从自己的多个隐藏向量中选出对应另一个特征所属的域的隐藏向量。构造 FFM 计算图的代码如下( ffm.py ):

from node import *

def field_aware_factorization_machine(n, fields, classes=2, latent_dim=6):
    """
    构造一个FFM模型的计算图,特征数量为 n 。 n 个特征能产生
    C(n, 2) 个二次交互项 xi * xj 。fields 说明特征如何归入 field
    """
    assert n == sum([len(f) for f in fields])
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # 二次特征,共C(n, 2)=n * (n-1) / 2个
    power2_n = int(n * (n - 1) / 2)
    x_2 = Variable(power2_n, init=False, trainable=False)
    # 域数量
    number_of_fields = len(fields)
    assert number_of_fields >= 1
    # 有多少类别,就有多少 logit
    logits = []
    for c in range(classes):
        # 一次部分,简单的将特征加权相加(求输入向量与权值向量的内积)
        order_1 = Dot(Variable(n, True), x)
        # 二次隐藏向量部分,首先为每个特征对每个 field 构造一个隐藏向量, latent_vectors 的
        # 第 i,k 元素是第 i 个特征对第 k 个 field 的隐藏向量
        latent_vectors = np.array([None] * n * number_of_fields).reshape(n, number_of_fields)
        for i in range(n):
            for j in range(len(fields)):
                latent_vectors[i, j] = Variable(latent_dim, True)
        # 每个二次交互项的权重是两个原始特征的隐藏向量的内积
        latent_weights = []
        for i in range(n):
            for j in range(i):
                # 寻找第 i 特征和第 j 特征所在的 field
                left_field = right_field = None
                for f in fields:
                    if i in f:
                        left_field = fields.index(f)
                    if j in f:
                        right_field = fields.index(f)
                latent_weights.append(Dot(latent_vectors[i, right_field], latent_vectors[j, left_field]))
        # 全部二次交互项的加权和
        order_2 = Dot(Vectorize(*latent_weights), x_2)
        # 偏置
        bias = Variable(1, True)
        # 将一次部分、二次部分和偏置相加
        logits.append(Add(Add(order_1, order_2), bias))
    # 将多个 logit 组装成 logits 向量
    logits = Vectorize(*logits)
    # 返回输入和logits
    return x, x_2, logits

代码含义自明,就不再赘述了。训练代码加上:

elif algo == "ffm":
    from ffm import field_aware_factorization_machine
    fields = [(0, 1, 3), (2, 4)]
    X, X_2, logits = field_aware_factorization_machine(n, fields, classes, latent_dim=7)

注意第二个参数 [(0, 4, 2), (3, 1)],它表示全部 5 个特征(0~4)分成 2 个域,第一个域包含特征 0、4 和 1,第二个域包含特征 3 和 1 。

FFM的计算图

DeepFM

DeepFM 也是一种 Wide & Deep ,它的 Wide 部分是一个 FM ,但是 Deep 部分不是简单地以原始一次特征作为神经网络的输入,而是用隐藏向量的元素作为多层全连接神经网络的输入。实际上 DeepFM 和 FM/FFM 一样,适用于特征都是 1/0 标志位,且可以归为若干个域的建模问题(例如推荐),这时候将原始特征分域做全连接,每个域包含同属一个原始类别特征的 One-Hot 标志位,为每个域单独做一个全连接层,该全连接层起到的作用是为原始类别特征的每一种可能性分配一个向量,这被称为 Embedding,即将表示众多可能性的高维 One-Hot 向量嵌入到低维向量空间中,属于“表示学习”。DeepFM 将 Embedding 出来的向量作为 FM 部分的隐藏向量,同时用这些向量的分量作为 Deep 部分的输入。我们这里不关注具体业务,也不要求输入是 One-Hot 标志,也不对它们分域,只是按照 DeepFM 的思想搭建一个计算图,代码如下( deepfm.py ):

from node import *

def deepfm(n, classes=2, hiddens=(12,), latent_dim=6):
    """
    构造一个FM模型的计算图,特征数量为 n 。 n 个特征能产生
    C(n, 2) 个二次交互项 xi * xj 。
    """
    # x 是一个 n 维向量变量,不初始化,不参与训练
    x = Variable(n, init=False, trainable=False)
    # 二次特征,共C(n, 2)=n * (n-1) / 2个
    power2_n = int(n * (n - 1) / 2)
    x_2 = Variable(power2_n, init=False, trainable=False)
    # 有多少类别,就有多少 logit
    logits = []
    for c in range(classes):
        # FM 部分
        # 一次部分,简单的将特征加权相加(求输入向量与权值向量的内积)
        order_1 = Dot(Variable(n, True), x)
        # 二次隐藏向量部分,首先为每个特征构造一个隐藏向量
        latent_vectors = []
        for i in range(n):
            latent_vectors.append(Variable(latent_dim, True))
        # 每个二次交互项的权重是两个原始特征的隐藏向量的内积
        latent_weights = []
        for i in range(n):
            for j in range(i):
                latent_weights.append(Dot(latent_vectors[i], latent_vectors[j]))
        # 全部二次交互项的加权和
        order_2 = Dot(Vectorize(*latent_weights), x_2)
        # 偏置
        bias = Variable(1, True)
        # 将一次部分、二次部分和偏置相加
        logits.append(Add(Add(order_1, order_2), bias))
    # 将多个 logit 组装成 wide 向量
    wide = Vectorize(*logits)
    # Deep 部分,以所有隐藏向量为输入的层,神经元个数为 hiddens[0]
    l = []
    for n in range(hiddens[0]):
        temp = []
        for v in latent_vectors:
            temp.append(Dot(Variable(latent_dim, True), v))
        w = Variable(len(latent_vectors), False, False)
        w.set_value(np.mat([1.0] * len(latent_vectors)).T)
        l.append(Dot(w, Vectorize(*temp)))
    output_size = hiddens[0]
    output = ReLU(Vectorize(*l))
    # 其余各层
    for h_size in hiddens[1:]:
        hidden = []
        for i in range(h_size):
            hidden.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
        # 隐藏层的输出
        output = ReLU(Vectorize(*hidden))
        output_size = h_size
    # 输出层,包含 classes 个神经元
    deep = []
    for i in range(classes):
        deep.append(Add(Dot(Variable(output_size, True), output), Variable(1, True)))
    deep = Vectorize(*deep)
    # 将Wide部分和Deep部分的输出相加,得到logits
    logits = Add(wide, deep)
    # 返回输入和logits
    return x, x_2, logits

这是本文最复杂的计算图。训练代码加上:

elif algo == "deepfm":
    from deepfm import deepfm
    X, X_2, logits = deepfm(n, classes, latent_dim=6)

DeepFM 的计算图如下:

DeepFM 的计算图

本文完整代码见于码云:

https://gitee.com/neural_network/neural_network_code/tree/master/%E7%AC%AC%208%20%E7%AB%A0%20%E8%AE%A1%E7%AE%97%E5%9B%BE