Java 深度学习项目(四)

原文:annas-archive.org/md5/d89c1af559e5f85f6b80756c31400c3f

译者:飞龙

协议:CC BY-NC-SA 4.0

第九章:使用深度强化学习玩 GridWorld 游戏

作为人类,我们通过经验学习。我们并非一夜之间或偶然变得如此迷人。多年的赞美与批评都帮助塑造了今天的我们。我们通过反复尝试不同的肌肉运动,直到学会骑自行车。当你执行动作时,有时会立刻获得奖励,这就是 强化学习RL)。

本章将专注于设计一个由批评和奖励驱动的机器学习系统。我们将看到如何使用 Deeplearning4jDL4J)、reinforcement learning 4jRL4J)和作为 Q 函数的神经网络 Q 学习开发一个示范性 GridWorld 游戏。我们将从强化学习及其理论背景开始,以便更容易理解这个概念。简而言之,本章将涵盖以下主题:

  • 强化学习中的符号表示、策略和效用

  • 深度 Q 学习算法

  • 使用深度 Q 学习开发 GridWorld 游戏

  • 常见问题解答(FAQ)

强化学习的符号表示、策略和效用

监督学习和无监督学习看似位于两个极端,而强化学习则介于二者之间。它不是监督学习,因为训练数据来自于算法在探索与利用之间做出的决策。

此外,它也不是无监督学习,因为算法会从环境中获取反馈。只要你处于一个执行某个动作后能够获得奖励的状态,你就可以使用强化学习来发现一系列能够获得最大期望奖励的动作。强化学习代理的目标是最大化最终获得的总奖励。第三个主要子元素是价值函数。

奖励决定了状态的即时可取性,而价值则表示了状态的长期可取性,考虑到可能跟随的状态和这些状态中的可用奖励。价值函数是相对于所选策略来指定的。在学习阶段,代理会尝试那些能够确定具有最高价值状态的动作,因为这些动作最终会带来最好的奖励。

强化学习技术已经被应用到许多领域。目前正在追求的一个总体目标是创建一个只需要任务描述的算法。当这种表现得以实现时,它将被广泛应用于各个领域。

强化学习中的符号表示

你可能注意到,强化学习的术语涉及将算法化身为在特定情境中采取动作以获取奖励。事实上,算法通常被称为一个与环境互动的代理。

你可以将其视为一个通过传感器感知并通过执行器与环境互动的智能硬件代理。因此,强化学习理论在机器人技术中的广泛应用也就不足为奇了。现在,为了进一步展开讨论,我们需要了解一些术语:

  • 环境:这是一个具有多个状态和在状态之间转换机制的系统。例如,在 GridWorld 游戏中,代理的环境就是网格空间本身,定义了状态以及代理如何通过奖励到达目标。

  • 代理:这是一个与环境互动的自主系统。例如,在我们的 GridWorld 游戏中,代理就是玩家。

  • 状态:环境中的状态是一组完全描述环境的变量。

  • 目标:它也是一个状态,提供比任何其他状态更高的折扣累计奖励。在我们的 GridWorld 游戏中,目标状态是玩家最终希望达到的状态,但通过积累尽可能高的奖励。

  • 动作:动作定义了不同状态之间的转换。因此,在执行一个动作后,代理可以从环境中获得奖励或惩罚。

  • 策略:它定义了一组基于动作的规则,用于在给定状态下执行和实施动作。

  • 奖励:这是对好坏动作/移动的正负量度(即得分)。最终,学习的目标是通过最大化得分(奖励)来达到目标。因此,奖励本质上是训练代理的训练集。

  • 回合(也称为试验):这是从初始状态(即代理的位置)到达目标状态所需的步骤数。

我们将在本节稍后讨论更多关于策略和效用的内容。下图展示了状态、动作和奖励之间的相互作用。如果你从状态 s[1] 开始,可以执行动作 a[1] 来获得奖励 r (s[1], a[1])。箭头表示动作,状态由圆圈表示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/9ff64270-027c-4839-b06b-01795d189ff3.png

当代理执行一个动作时,状态会产生奖励。

机器人执行动作以在不同状态之间转换。但它如何决定采取哪种动作呢?实际上,这完全依赖于使用不同的或具体的策略。

政策

在强化学习中,策略是一组规则或一种战略。因此,学习的一个目标是发现一种良好的策略,能够观察到每个状态下动作的长期后果。所以,从技术上讲,策略定义了在给定状态下要采取的行动。下图展示了在任何状态下的最优动作:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/2762d760-cfcf-4ac5-9d6b-fba6784b50fd.png

策略定义了在给定状态下要采取的动作。

短期后果很容易计算:它只是奖励。尽管执行一个动作会得到即时奖励,但贪心地选择最大奖励的动作并不总是最好的选择。根据你的强化学习问题的定义,可能会有不同类型的策略,如下所述:

  • 当一个代理总是通过执行某个动作来追求最高的即时奖励时,我们称之为贪心策略

  • 如果一个动作是任意执行的,则该策略称为随机策略

  • 当神经网络通过反向传播和来自环境的明确反馈更新权重来学习选择动作的策略时,我们称之为策略梯度

如果我们想要制定一个健壮的策略来解决强化学习问题,我们必须找到一个在表现上优于随机策略和贪心策略的最优策略。在这一章中,我们将看到为什么策略梯度更加直接和乐观。

效用

长期奖励是效用。为了决定采取什么动作,代理可以选择产生最高效用的动作,并以贪心方式进行选择。执行一个动作 a 在状态 s 时的效用表示为函数 Q(s, a),称为效用函数。效用函数根据由状态和动作组成的输入,预测即时奖励和最终奖励,正如以下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/b01b6793-f371-4a12-96a4-7a9f74c661e4.png

使用效用函数

神经网络 Q 学习

大多数强化学习算法可以归结为三个主要步骤:推断、执行和学习。在第一步中,算法使用到目前为止所获得的知识,从给定的状态 s 中选择最佳动作 a。接下来,它执行该动作,以找到奖励 r 和下一个状态 s’

然后,它使用新获得的知识 (s, r, a, s’) 来改进对世界的理解。这些步骤甚至可以通过 Q 学习算法进行更好的公式化,Q 学习算法或多或少是深度强化学习的核心。

Q 学习简介

使用 (s, r, a, s’) 计算获得的知识只是计算效用的一种简单方法。因此,我们需要找到一种更健壮的方式来计算它,使得我们通过递归地考虑未来动作的效用来计算特定状态-动作对 (s, a) 的效用。当前动作的效用不仅受到即时奖励的影响,还受到下一个最佳动作的影响,如下式所示,这称为Q 函数

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/54fe0db0-e6ab-426a-a24a-fbe697d49bcf.png

在前面的公式中,s’ 表示下一个状态,a’ 表示下一个动作,执行动作 a 在状态 s 时的奖励表示为 r(s, a)。其中,γ 是一个超参数,称为折扣因子。如果 γ0,则代理选择一个特定的动作,最大化即时奖励。较高的 γ 值将使代理更重视考虑长期后果。

在实践中,我们需要考虑更多这样的超参数。例如,如果期望吸尘机器人快速学习解决任务,但不一定要求最优解,那么我们可能会设置一个更高的学习速率。

另外,如果允许机器人有更多的时间去探索和利用,我们可能会降低学习速率。我们将学习速率称为α,并将我们的效用函数更改如下(请注意,当α = 1时,这两个方程是相同的):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/52146aac-6bcb-412c-b6f9-b5d38208f74a.jpg

总结来说,一个 RL 问题可以通过了解这个*Q(s, a)*函数来解决。这促使研究人员提出了一种更先进的QLearning算法,称为神经 Q 学习,它是一种用于计算状态-动作值的算法,属于时序差分TD)算法类别,意味着它涉及到动作执行和奖励获得之间的时间差异。

神经网络作为 Q 函数

现在我们知道了状态和需要执行的动作。然而,QLearning智能体需要了解形如(状态 x 动作)的搜索空间。下一步是创建图形或搜索空间,它是负责任何状态序列的容器。QLSpace类定义了QLearning算法的搜索空间(状态 x 动作),如下面的图所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/20fae0f1-19b4-47ff-a3f5-b8a228aec422.png

状态转移矩阵与 QLData(Q 值、奖励、概率)

拥有状态和动作列表的最终用户可以提供搜索空间。或者,它可以通过提供状态数来自动创建,具体通过以下参数:

  • 状态:Q 学习搜索空间中定义的所有可能状态的序列

  • 目标:一系列表示目标状态的标识符

然而,传统的这种搜索空间(或查找表)表示方式有时效率不高;因为在大多数有趣的问题中,我们的状态-动作空间太大,无法存储在表中,例如吃豆人游戏。相反,我们需要进行泛化,并在状态之间进行模式匹配。换句话说,我们需要我们的 Q 学习算法能够说,这种状态的值是 X,而不是说,这个特定、超具体的状态的值是 X

这里可以使用基于神经网络的 Q 学习,而不是查找表作为我们的Q(s, a),它接受状态s和动作a,并输出该状态-动作对的值。然而,正如我之前提到的,神经网络有时包含数百万个与之相关的参数,这些参数就是权重。因此,我们的Q函数实际上看起来像Q(s, a, θ),其中θ是一个参数向量。

我们将通过反复更新神经网络的θ参数来代替反复更新表格中的值,从而使其学会为我们提供更好的状态-动作值估计。顺便提一下,我们可以像训练其他神经网络一样,使用梯度下降(反向传播)来训练这样的深度 Q 学习网络。

例如,如果状态(搜索空间)通过图像表示,神经网络可以对智能体的可能动作进行排名,从而预测可能的奖励。例如,向左跑返回五分,向上跳返回七分,向下跳返回两分,而向左跑则不返回任何奖励。

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/2dd9f59b-8fc8-4f23-a414-c19e938a5bc4.png

使用神经网络进行基于强化学习的游戏

为了实现这一点,我们不需要为每个动作都运行网络,而是只需在我们需要获取max Qs′,a′)时运行它,也就是在新状态s’下对每个可能的动作获取max Q值。

我们将看到如何使用MultiLayerNetwork和 DL4J 的MultiLayerConfiguration配置创建这样的深度 Q 学习网络。因此,神经网络将充当我们的 Q*-*函数。现在,我们已经对强化学习(RL)和 Q 学习有了最基本的理论了解,是时候开始编写代码了。

使用深度 Q 网络开发 GridWorld 游戏

现在我们将深入了解深度 Q 网络DQN),以训练一个智能体玩 GridWorld,这是一个简单的基于文本的游戏。游戏中有一个 4x4 的方格,放置了四个物体:一个智能体(玩家)、一个陷阱、一个目标和一堵墙。

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/109cb766-7ba7-4c1d-b8b6-4d53282de1e5.png

GridWorld 项目结构

项目具有以下结构:

  • DeepQNetwork.java:提供 DQN 的参考架构

  • Replay.java:生成 DQN 的重放记忆,确保深度网络的梯度稳定,不会在多个回合中发散

  • GridWorld.java:用于训练 DQN 和玩游戏的主类。

顺便提一下,我们在 GPU 和 cuDNN 上执行训练,以加快收敛速度。如果您的机器没有 GPU,您也可以使用 CPU 后端。

生成网格

我们将开发一个简单的游戏,每次初始化一个完全相同的网格。游戏从智能体(A)、目标(+)、陷阱(-)和墙(W)开始。每场游戏中,所有元素都被随机放置在网格上。这样,Q 学习只需要学习如何将智能体从已知的起始位置移动到已知的目标位置,而不碰到陷阱(这会带来负面奖励)。请看这张截图:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/a59c6f73-11ff-4199-8fd7-25476b7e85b0.png

一个显示游戏元素(即智能体、目标、陷阱和墙)的 GridWorld 游戏网格

简而言之,游戏的目标是到达目标点,在那里智能体会获得一个数值奖励。为了简化,我们将避免陷阱;如果智能体落入陷阱,它将被处罚,获得负奖励。

墙壁也能阻挡代理人的路径,但它不会提供奖励或惩罚,所以我们可以放心。由于这是定义状态的一种简单方式,代理人可以执行以下动作(即,行为):

  • 向上

  • 向下

  • 向左

  • 向右

这样,动作 a 可以定义为如下:a ∈ A {up, down, left, right}。现在让我们看看,基于前面的假设,网格会是什么样子的:

// Generate the GridMap
int size = 4;
float[][] generateGridMap() {
        int agent = rand.nextInt(size * size);
        int goal = rand.nextInt(size * size);

        while(goal == agent)
            goal = rand.nextInt(size * size);
        float[][] map = new float[size][size];

        for(int i = 0; i < size * size; i++)
            map[i / size][i % size] = 0;
        map[goal / size][goal % size] = -1;
        map[agent / size][agent % size] = 1;

        return map;
    }

一旦网格构建完成,可以按如下方式打印出来:

void printGrid(float[][] Map) {
        for(int x = 0; x < size; x++) {
            for(int y = 0; y < size; y++) {
                System.out.print((int) Map[x][y]);
            }
            System.out.println(" ");
        }
        System.out.println(" ");
    }

计算代理人和目标位置

现在,代理人的搜索空间已经准备好。接下来,让我们计算代理人和目标的初始位置。首先,我们计算代理人在网格中的初始位置,如下所示:

// Calculate the position of agent
int calcAgentPos(float[][] Map) {
        int x = -1;
        for(int i = 0; i < size * size; i++) {
            if(Map[i / size][i % size] == 1)
                return i;
        }
        return x;
    }

然后我们计算目标的位置,如下所示:

// Calculate the position of goal. The method takes the grid space as input
int calcGoalPos(float[][] Map) {
        int x = -1;// start from the initial position

        // Then we loop over the grid size say 4x4 times
        for(int i = 0; i < size * size; i++) {
            // If the mapped position is the initial position, we update the position 
            if(Map[i / size][i % size] == -1)
                return i;
        }
        return x; // agent cannot move to any other cell
    }

现在,生成的网格可以视为四个独立的网格平面,每个平面代表每个元素的位置。在下图中,代理人当前的网格位置是 (3, 0),墙壁位于 (0, 0),陷阱位于 (0, 1),目标位于 (1, 0),这也意味着所有其他元素为 0:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/826ed961-0272-49c2-9a9c-d8bd9a35bd10.png

生成的网格可以视为四个独立的网格平面

因此,我们设计了网格,使得某些物体在相同的 xy 位置(但不同的 z 位置)包含一个 1,这表示它们在网格上的位置相同。

计算动作掩码

在这里,我们将所有输出设置为 0,除了我们实际看到的动作对应的输出,这样网络就可以根据与独热编码动作对应的掩码来乘以输出。然后我们可以将 0 作为所有未知动作的目标,这样我们的神经网络应该能够很好地执行。当我们想要预测所有动作时,可以简单地传递一个全为 1 的掩码:

// Get action mask
int[] getActionMask(float[][] CurrMap) {
        int retVal[] = { 1, 1, 1, 1 };

        int agent = calcAgentPos(CurrMap); //agent current position
        if(agent < size) // if agent's current pos is less than 4, action mask is set to 0
            retVal[0] = 0;
        if(agent >= (size * size - size)) // if agent's current pos is 12, we set action mask to 0 too
            retVal[1] = 0;
        if(agent % size == 0) // if agent's current pos is 0 or 4, we set action mask to 0 too
            retVal[2] = 0;
        if(agent % size == (size - 1))// if agent's current pos is 7/11/15, we set action mask to 0 too
            retVal[3] = 0;

        return retVal; // finally, we return the updated action mask. 
    }

提供指导动作

现在代理人的行动计划已经确定。接下来的任务是为代理人提供一些指导,使其从当前的位置朝着目标前进。例如,并非所有的动作都是准确的,也就是说,某些动作可能是无效的:

// Show guidance move to agent 
float[][] doMove(float[][] CurrMap, int action) {
        float nextMap[][] = new float[size][size];
        for(int i = 0; i < size * size; i++)
            nextMap[i / size][i % size] = CurrMap[i / size][i % size];

        int agent = calcAgentPos(CurrMap);
        nextMap[agent / size][agent % size] = 0;

        if(action == 0) {
            if(agent - size >= 0)
                nextMap[(agent - size) / size][agent % size] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if(action == 1) {
            if(agent + size < size * size)
                nextMap[(agent + size) / size][agent % size] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if (action == 2) {
            if((agent % size) - 1 >= 0)
                nextMap[agent / size][(agent % size) - 1] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        } else if(action == 3) {
            if((agent % size) + 1 < size)
                nextMap[agent / size][(agent % size) + 1] = 1;
            else {
                System.out.println("Bad Move");
                System.exit(0);
            }
        }
        return nextMap;
    }

在前面的代码块中,我们将动作编码为如下:0代表向上,1代表向下,2代表向左,3代表向右。否则,我们将该动作视为无效操作,代理人将受到惩罚。

计算奖励

现在,代理人已经获得了一些指导——强化信号——接下来的任务是计算代理人执行每个动作的奖励。看看这段代码:

// Compute reward for an action 
float calcReward(float[][] CurrMap, float[][] NextMap) {
        int newGoal = calcGoalPos(NextMap);// first, we calculate goal position for each map
        if(newGoal == -1) // if goal position is the initial position (i.e. no move)
            return (size * size + 1); // we reward the agent to 4*4+ 1 = 17 (i.e. maximum reward)
        return -1f; // else we reward -1.0 for each bad move 
    }

为输入层展平输入

然后我们需要将网络的输出转换为一个 1D 特征向量,供 DQN 使用。这个展平过程获取了网络的输出;它将所有结构展平,形成一个单一的长特征向量,供全连接层使用。看看这段代码:

INDArray flattenInput(int TimeStep) {
        float flattenedInput[] = new float[size * size * 2 + 1];

        for(int a = 0; a < size; a++) {
            for(int b = 0; b < size; b++) {
                if(FrameBuffer[a][b] == -1)
                    flattenedInput[a * size + b] = 1;
                else
                    flattenedInput[a * size + b] = 0;
                if(FrameBuffer[a][b] == 1)
                    flattenedInput[size * size + a * size + b] = 1;
                else
                    flattenedInput[size * size + a * size + b] = 0;
            }
        }
        flattenedInput[size * size * 2] = TimeStep;
        return Nd4j.create(flattenedInput);
    }

到目前为止,我们仅创建了 GridWorld 的逻辑框架。因此,我们在开始游戏之前创建了 DQN

网络构建与训练

正如我所说,我们将使用 MultiLayerNetwork 和 DL4J 的 MultiLayerConfiguration 配置创建一个 DQN 网络,它将作为我们的 Q 函数。因此,第一步是通过定义 MultiLayerConfiguration 创建一个 MultiLayerNetwork。由于状态有 64 个元素—4 x 4 x 4—我们的网络需要一个包含 64 个单元的输入层,两个隐藏层,分别有 164 和 150 个单元,以及一个包含 4 个单元的输出层,用于四种可能的动作(上、下、左、右)。具体如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/e4134ff8-c90a-4cbb-947b-d7b4efa6c89d.png

DQN 网络的结构,显示输入层、两个隐藏层和输出层

然而,我们将使用经验回放内存来训练我们的 DQN,它将帮助我们存储智能体观察到的转换。这将允许 DQN 在后续使用这些数据。通过从中随机采样,构建一个批次的转换可以去相关。研究表明,这大大稳定并改善了 DQN 的训练过程。按照前述配置,以下代码可用于创建这样的 MultiLayerConfiguration

int InputLength = size * size * 2 + 1;
int HiddenLayerCount = 150;

MultiLayerConfiguration conf = new NeuralNetConfiguration.Builder()
                .seed(12345)    //Random number generator seed for improved repeatability. Optional.
                .optimizationAlgo(OptimizationAlgorithm.STOCHASTIC_GRADIENT_DESCENT)
                .weightInit(WeightInit.XAVIER)
                .updater(new Adam(0.001))
                .l2(0.001) // l2 regularization on all layers
                .list()
                .layer(0, new DenseLayer.Builder()
                        .nIn(InputLength)
                        .nOut(HiddenLayerCount)
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU)
                        .build())
                .layer(1, new DenseLayer.Builder()
                        .nIn(HiddenLayerCount)
                        .nOut(HiddenLayerCount)
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.RELU)
                        .build())
                .layer(2,new OutputLayer.Builder(LossFunction.MSE)
                        .nIn(HiddenLayerCount)
                        .nOut(4) // for 4 possible actions
                        .weightInit(WeightInit.XAVIER)
                        .activation(Activation.IDENTITY)
                        .weightInit(WeightInit.XAVIER)
                        .build())
                .pretrain(false).backprop(true).build();

然后,我们使用这个配置创建一个 DQN:

DeepQNetwork RLNet = new DeepQNetwork(conf, 100000, .99f, 1d, 1024, 500, 1024, InputLength, 4);

我们稍后会讨论参数,但在此之前,我们先看看如何创建这样一个深度架构。首先,我们定义一些参数:

int ReplayMemoryCapacity;
List<Replay> ReplayMemory;
double Epsilon;
float Discount;

MultiLayerNetwork DeepQ; // Initial DeepQNet
MultiLayerNetwork TargetDeepQ; // Target DeepQNet

int BatchSize;
int UpdateFreq;
int UpdateCounter;
int ReplayStartSize;
Random r;

int InputLength;
int NumActions;

INDArray LastInput;
int LastAction;

然后,我们定义构造函数来初始化这些参数:

DeepQNetwork(MultiLayerConfiguration conf, int replayMemoryCapacity, float discount, double epsilon, int batchSize, int updateFreq, int replayStartSize, int inputLength, int numActions){
        // First, we initialize both the DeepQNets
 DeepQ = new MultiLayerNetwork(conf);
        DeepQ.init();

        TargetDeepQ = new MultiLayerNetwork(conf);
        TargetDeepQ.init();

        // Then we initialize the target DeepQNet's params
        TargetDeepQ.setParams(DeepQ.params());
        ReplayMemoryCapacity = replayMemoryCapacity;

        Epsilon = epsilon;
        Discount = discount;

        r = new Random();
        BatchSize = batchSize;
        UpdateFreq = updateFreq;
        UpdateCounter = 0;

        ReplayMemory = new ArrayList<Replay>();
        ReplayStartSize = replayStartSize;
        InputLength = inputLength;
        NumActions = numActions;
    }

以下是该算法主循环的实现:

  1. 我们设置了一个 for 循环,直到游戏进行完毕。

  2. 我们运行 Q 网络的前向传播。

  3. 我们使用的是 epsilon-贪心策略,因此在时间 t 时,以 ϵ 的概率,智能体 选择一个随机行动。然而,以 1−ϵ 的概率,执行来自我们神经网络的最大 Q 值对应的行动。

  4. 然后,智能体采取一个行动 a,该行动在前一步骤中已确定;我们观察到一个新的状态 s′ 和奖励 r[t][+1]。

  5. 然后,使用 s′ 执行 Q 网络的前向传播,并存储最高的 Q 值(maxQ)。

  6. 然后,计算智能体的目标值作为奖励 + (gamma * maxQ),用于训练网络,其中 gamma 是一个参数(0<=γ<=1)。

  7. 我们的目标是更新与我们刚刚采取的行动相关联的四种可能输出的输出。在这里,智能体的目标输出向量与第一次执行时的输出向量相同,唯一不同的是与行动相关联的输出 奖励 + (gamma * maxQ)

上述步骤是针对一个回合的,然后循环会根据用户定义的回合数进行迭代。此外,首先构建网格,然后计算并保存每个动作的下一个奖励。简而言之,上述步骤可以表示如下:

GridWorld grid = new GridWorld();
grid.networkConstruction();

// We iterate for 100 episodes
for(int m = 0; m < 100; m++) {
            System.out.println("Episode: " + m);
            float CurrMap[][] = grid.generateGridMap();

            grid.FrameBuffer = CurrMap;
            int t = 0;
            grid.printGrid(CurrMap);

            for(int i = 0; i < 2 * grid.size; i++) {
                int a = grid.RLNet.getAction(grid.flattenInput(t), grid.getActionMask(CurrMap));

                float NextMap[][] = grid.doMove(CurrMap, a);
                float r = grid.calcReward(CurrMap, NextMap);
                grid.addToBuffer(NextMap);
                t++;

                if(r == grid.size * grid.size + 1) {
                    grid.RLNet.observeReward(r, null, grid.getActionMask(NextMap));
                    break;
                }

                grid.RLNet.observeReward(r, grid.flattenInput(t), grid.getActionMask(NextMap));
                CurrMap = NextMap;
            }
}

在前面的代码块中,网络计算每个迷你批次的扁平化输入数据所观察到的奖励。请看一下:

void observeReward(float Reward, INDArray NextInputs, int NextActionMask[]){
        addReplay(Reward, NextInputs, NextActionMask);

        if(ReplayStartSize <  ReplayMemory.size())
            networkTraining(BatchSize);
        UpdateCounter++;
        if(UpdateCounter == UpdateFreq){
            UpdateCounter = 0;
            System.out.println("Reconciling Networks");
            reconcileNetworks();
        }
    }    

上述奖励被计算出来,用于估算最优的未来值:

int getAction(INDArray Inputs , int ActionMask[]){
        LastInput = Inputs;
        INDArray outputs = DeepQ.output(Inputs);

        System.out.print(outputs + " ");
        if(Epsilon > r.nextDouble()) {
             LastAction = r.nextInt(outputs.size(1));
             while(ActionMask[LastAction] == 0)
                 LastAction = r.nextInt(outputs.size(1));
             System.out.println(LastAction);
             return LastAction;
        }        
        LastAction = findActionMax(outputs , ActionMask);
        System.out.println(LastAction);
        return LastAction;
    }

在前面的代码块中,通过取神经网络输出的最大值来计算未来的奖励。来看一下这个:

int findActionMax(INDArray NetOutputs , int ActionMask[]){
        int i = 0;
        while(ActionMask[i] == 0) i++;

        float maxVal = NetOutputs.getFloat(i);
        int maxValI = i;

        for(; i < NetOutputs.size(1) ; i++){
            if(NetOutputs.getFloat(i) > maxVal && ActionMask[i] == 1){
                maxVal = NetOutputs.getFloat(i);
                maxValI = i;
            }
        }
        return maxValI;
    }    

如前所述,观察到的奖励是在网络训练开始后计算的。组合输入的计算方式如下:

INDArray combineInputs(Replay replays[]){
        INDArray retVal = Nd4j.create(replays.length , InputLength);
        for(int i = 0; i < replays.length ; i++){
            retVal.putRow(i, replays[i].Input);
        }
        return retVal;
    }

然后,网络需要计算下一次传递的组合输入。来看一下这段代码:

INDArray combineNextInputs(Replay replays[]){
        INDArray retVal = Nd4j.create(replays.length , InputLength);
        for(int i = 0; i < replays.length ; i++){
            if(replays[i].NextInput != null)
                retVal.putRow(i, replays[i].NextInput);
        }
        return retVal;
    }

在之前的代码块中,每个时间步的地图通过addToBuffer()方法保存,如下所示:

void addToBuffer(float[][] nextFrame) { 
          FrameBuffer = nextFrame;
}

然后,DQNet 将输入展平后以批量的方式输入每一回合,开始训练。然后根据当前输入和目标输入,通过最大化奖励来计算当前和目标输出。来看一下这个代码块:

void networkTraining(int BatchSize){
        Replay replays[] = getMiniBatch(BatchSize);
        INDArray CurrInputs = combineInputs(replays);
        INDArray TargetInputs = combineNextInputs(replays);

        INDArray CurrOutputs = DeepQ.output(CurrInputs);
        INDArray TargetOutputs = TargetDeepQ.output(TargetInputs);

        float y[] = new float[replays.length];
        for(int i = 0 ; i < y.length ; i++){
            int ind[] = { i , replays[i].Action };
            float FutureReward = 0 ;
            if(replays[i].NextInput != null)
                FutureReward = findMax(TargetOutputs.getRow(i) , replays[i].NextActionMask);
            float TargetReward = replays[i].Reward + Discount * FutureReward ;
            CurrOutputs.putScalar(ind , TargetReward ) ;
        }
        //System.out.println("Avgerage Error: " + (TotalError / y.length) );

        DeepQ.fit(CurrInputs, CurrOutputs);
    }

在前面的代码块中,通过最大化神经网络输出的值来计算未来的奖励,如下所示:

float findMax(INDArray NetOutputs , int ActionMask[]){
        int i = 0;
        while(ActionMask[i] == 0) i++;

        float maxVal = NetOutputs.getFloat(i);
        for(; i < NetOutputs.size(1) ; i++){
            if(NetOutputs.getFloat(i) > maxVal && ActionMask[i] == 1){
                maxVal = NetOutputs.getFloat(i);
            }
        }
        return maxVal;
    }

正如我之前所说,这是一个非常简单的游戏,如果智能体采取动作 2(即,向左),一步就能到达目标。因此,我们只需保持所有其他输出与之前相同,改变我们所采取动作的输出。所以,实现经验回放是一个更好的主意,它在在线学习方案中给我们提供了小批量更新。

它的工作方式是我们运行智能体收集足够的过渡数据来填充回放记忆,而不进行训练。例如,我们的记忆大小可能为 10,000。然后,在每一步,智能体将获得一个过渡;我们会将其添加到记忆的末尾,并弹出最早的一个。来看一下这段代码:

void addReplay(float reward , INDArray NextInput , int NextActionMask[]){
        if(ReplayMemory.size() >= ReplayMemoryCapacity )
            ReplayMemory.remove( r.nextInt(ReplayMemory.size()) );

        ReplayMemory.add(new Replay(LastInput , LastAction , reward , NextInput , NextActionMask));
    }

然后,从记忆中随机抽取一个小批量的经验,并在其上更新我们的 Q 函数,类似于小批量梯度下降。来看一下这段代码:

Replay[] getMiniBatch(int BatchSize){
        int size = ReplayMemory.size() < BatchSize ? ReplayMemory.size() : BatchSize ;
        Replay[] retVal = new Replay[size];

        for(int i = 0 ; i < size ; i++){
            retVal[i] = ReplayMemory.get(r.nextInt(ReplayMemory.size()));
        }
        return retVal;        
    }

玩 GridWorld 游戏

对于这个项目,我没有使用任何可视化来展示状态和动作,而是采用了一种基于文本的游戏,正如我之前提到的那样。然后你可以运行GridWorld.java类(包含主方法),使用以下方式调用:

DeepQNetwork RLNet = new DeepQNetwork(conf, 100000, .99f, 1d, 1024, 500, 1024, InputLength, 4);

在这个调用中,以下是参数描述:

  • conf:这是用于创建 DQN 的MultiLayerConfiguration

  • 100000:这是回放记忆的容量。

  • .99f:折扣因子

  • 1d:这是 epsilon

  • 1024:批量大小

  • 500:这是更新频率;第二个 1,024 是回放开始的大小

  • InputLength:这是输入的长度,大小为 x x 2 + 1 = 33(考虑到 size=4)

  • 4:这是智能体可以执行的可能动作的数量。

我们将 epsilon(ϵ贪婪动作选择)初始化为 1,并且在每一回合后会减少一个小量。这样,最终它会降到 0.1 并保持不变。基于之前的设置,应该开始训练,训练过程会开始生成一个表示每个时间戳的地图网格,并输出 DQN 对于上/下/左/右顺序的结果,接着是最高值的索引。

我们没有用于图形表示游戏的模块。所以,在前面的结果中,0、1、-1 等数字代表了每五个回合中每个时间戳的地图。括号中的数字只是 DQN 的输出,后面跟着最大值的索引。看看这个代码块:

Scanner keyboard = new Scanner(System.in);
for(int m = 0; m < 10; m++) {
            grid.RLNet.SetEpsilon(0);
            float CurrMap[][] = grid.generateGridMap();
            grid.FrameBuffer = CurrMap;

            int t = 0;
            float tReward = 0;

            while(true) {
                grid.printGrid(CurrMap);
                keyboard.nextLine();

                int a = grid.RLNet.getAction(grid.flattenInput(t), grid.getActionMask(CurrMap));
                float NextMap[][] = grid.doMove(CurrMap, a);
                float r = grid.calcReward(CurrMap, NextMap);

                tReward += r;
                grid.addToBuffer(NextMap);
                t++;
                grid.RLNet.observeReward(r, grid.flattenInput(t), grid.getActionMask(NextMap));

                if(r == grid.size * grid.size + 1)
                    break;
                CurrMap = NextMap;
            }
            System.out.println("Net Score: " + (tReward));
        }
        keyboard.close();
    }

>>>
 Episode: 0
 0000
 01-10
 0000
 0000
 [[ 0.2146, 0.0337, -0.0444, -0.0311]] 2
 [[ 0.1105, 0.2139, -0.0454, 0.0851]] 0
 [[ 0.0678, 0.3976, -0.0027, 0.2667]] 1
 [[ 0.0955, 0.3379, -0.1072, 0.2957]] 3
 [[ 0.2498, 0.2510, -0.1891, 0.4132]] 0
 [[ 0.2024, 0.4142, -0.1918, 0.6754]] 2
 [[ 0.1141, 0.6838, -0.2850, 0.6557]] 1
 [[ 0.1943, 0.6514, -0.3886, 0.6868]] 0
 Episode: 1
 0000
 0000
 1000
 00-10
 [[ 0.0342, 0.1792, -0.0991, 0.0369]] 0
 [[ 0.0734, 0.2147, -0.1360, 0.0285]] 1
 [[ 0.0044, 0.1295, -0.2472, 0.1816]] 3
 [[ 0.0685, 0.0555, -0.2153, 0.2873]] 0
 [[ 0.1479, 0.0558, -0.3495, 0.3527]] 3
 [[ 0.0978, 0.3776, -0.4362, 0.4475]] 0
 [[ 0.1010, 0.3509, -0.4134, 0.5363]] 2
 [[ 0.1611, 0.3717, -0.4411, 0.7929]] 3
 ....
 Episode: 9
 0000
 1-100
 0000
 0000
 [[ 0.0483, 0.2899, -0.1125, 0.0281]] 3
 0000
 0000
 0-101
 0000
 [[ 0.0534, 0.2587, -0.1539, 0.1711]] 1
 Net Score: 10.0

因此,代理已经能够获得总分 10(即正分)。

常见问题解答(FAQs)

既然我们已经解决了 GridWorld 问题,那么在强化学习和整体深度学习现象中还有其他实际方面需要考虑。在本节中,我们将看到一些你可能已经想过的常见问题。答案可以在附录中找到。

  1. 什么是 Q 学习中的 Q?

  2. 我理解我们在 GPU 和 cuDNN 上进行了训练以加速收敛。然而,我的机器上没有 GPU。我该怎么办?

  3. 没有可视化,因此很难跟踪代理朝向目标的移动。

  4. 给出更多强化学习的例子。

  5. 我如何调和我们获得的小批处理处理结果?

  6. 我如何调和 DQN?

  7. 我想保存已训练的网络。我可以做到吗?

  8. 我想恢复已保存(即已训练)的网络。我可以做到吗?

总结

在本章中,我们展示了如何使用 DL4J、RL4J 和神经 Q 学习开发一个演示版 GridWorld 游戏,其中 Q 学习充当 Q 函数。我们还提供了开发深度 Q 学习网络以玩 GridWorld 游戏所需的一些基本理论背景。然而,我们没有开发任何模块来可视化代理在整个回合中的移动。

在下一章,我们将开发一个非常常见的端到端电影推荐系统项目,但使用神经因式分解机FM)算法。该项目将使用 MovieLens 100 万数据集。我们将使用 RankSys 和基于 Java 的 FM 库来预测用户的电影评分和排名。尽管如此,Spark ML 将用于对数据集进行探索性分析。

问题的答案

问题 1 的回答: 不要将 Q 学习中的 Q 与我们在前面部分讨论的 Q 函数混淆。Q 函数始终是接受状态和动作并返回该状态-动作对值的函数名称。强化学习方法涉及 Q 函数,但不一定是 Q 学习算法。

问题 2 的回答: 不用担心,你也可以在 CPU 后端进行训练。在这种情况下,只需从 pom.xml 文件中删除与 CUDA 和 cuDNN 相关的条目,并用 CPU 版本替换它们。相应的属性为:

<properties>
       <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
       <java.version>1.8</java.version>
        <nd4j.version>1.0.0-alpha</nd4j.version>
       <dl4j.version>1.0.0-alpha</dl4j.version>
      <datavec.version>1.0.0-alpha</datavec.version>
       <arbiter.version>1.0.0-alpha</arbiter.version>
      <logback.version>1.2.3</logback.version>
</properties>

不要使用这两个依赖项:

<dependency>
       <groupId>org.nd4j</groupId>
        <artifactId>nd4j-cuda-9.0-platform</artifactId>
        <version>${nd4j.version}</version>
</dependency>
<dependency>
       <groupId>org.deeplearning4j</groupId>
       <artifactId>deeplearning4j-cuda-9.0</artifactId>
       <version>${dl4j.version}</version>
</dependency>

只使用一个,如下所示:

<dependency>
     <groupId>org.nd4j</groupId>
     <artifactId>nd4j-native</artifactId>
     <version>${nd4j.version}</version>
</dependency>

那么你已经准备好使用 CPU 后端了。

问题 3 的答案: 如前所述,最初的目标是开发一个简单的基于文本的游戏。然而,通过一些努力,所有的动作也可以进行可视化。我希望读者自行决定。然而,可视化模块将在不久后添加到 GitHub 仓库中。

问题 4 的答案: 好吧,DL4J GitHub 仓库中有一些 RL4J 的基本示例,地址是github.com/deeplearning4j/dl4j-examples/。欢迎尝试扩展它们以满足你的需求。

问题 5 的答案: 处理每个小批次可以为该小批次使用的输入提供最佳的权重/偏置结果。这个问题涉及到几个子问题:i) 我们如何调和所有小批次得到的结果?ii) 我们是否取平均值来得出训练网络的最终权重/偏置?

因此,每个小批次包含单独错误梯度的平均值。如果你有两个小批次,你可以取这两个小批次的梯度更新的平均值来调整权重,以减少这些样本的误差。

问题 6 的答案: 参考问题 5 以获得理论理解。然而,在我们的示例中,使用来自 DL4J 的setParams()方法,它有助于调和网络:

void reconcileNetworks(){
     TargetDeepQ.setParams(DeepQ.params());
    }

现在问题是:我们在哪些地方使用这种调和方法呢?答案是,在计算奖励时(参见observeReward()方法)。

问题 7 的答案: 保存 DQN 与保存其他基于 DL4J 的网络类似。为此,我编写了一个名为saveNetwork()的方法,它将网络参数作为单个 ND4J 对象以 JSON 格式保存。看一下这个:

public boolean saveNetwork(String ParamFileName , String JSONFileName){
        //Write the network parameters for later use:
        try(DataOutputStream dos = new DataOutputStream(Files.newOutputStream(Paths.get(ParamFileName)))){
            Nd4j.write(DeepQ.params(),dos);
        } catch(IOException e) {
            System.out.println("Failed to write params");
            return false;
        }

        //Write the network configuration:
        try{
            FileUtils.write(new File(JSONFileName), DeepQ.getLayerWiseConfigurations().toJson());
        } catch (IOException e) {
            System.out.println("Failed to write json");
            return false;
        }
        return true;
    }

问题 8 的答案: 恢复 DQN 与保存其他基于 DL4J 的网络类似。为此,我编写了一个名为restoreNetwork()的方法,它调和参数并将保存的网络重新加载为MultiLayerNetwork。如下所示:

public boolean restoreNetwork(String ParamFileName , String JSONFileName){
        //Load network configuration from disk:
        MultiLayerConfiguration confFromJson;
        try{
            confFromJson = MultiLayerConfiguration.fromJson(FileUtils.readFileToString(new 
                                                            File(JSONFileName)));
        } catch(IOException e1) {
            System.out.println("Failed to load json");
            return false;
        }

        //Load parameters from disk:
        INDArray newParams;
        try(DataInputStream dis = new DataInputStream(new FileInputStream(ParamFileName))){
            newParams = Nd4j.read(dis);
        } catch(FileNotFoundException e) {
            System.out.println("Failed to load parems");
            return false;
        } catch (IOException e) {
            System.out.println("Failed to load parems");
            return false;
        }
        //Create a MultiLayerNetwork from the saved configuration and parameters 
        DeepQ = new MultiLayerNetwork(confFromJson); 
        DeepQ.init(); 

        DeepQ.setParameters(newParams); 
        reconcileNetworks();
        return true;        
    }   

第十章:使用因式分解机开发电影推荐系统

因式分解机FM)是一组通过引入第二阶特征交互来增强线性模型性能的算法,这些交互在矩阵分解MF)算法中是缺失的,并且这种增强方式是监督式的。因此,相比于经典的协同过滤CF)方法,因式分解机非常稳健,并且因其能够用于发现两种不同实体之间交互的潜在特征,在个性化和推荐系统中越来越受欢迎。

在本章中,我们将开发一个样例项目,用于预测评分和排名,以展示其有效性。尽管如此,在使用基于 RankSys 库的 FM 实现项目之前,我们将看到一些关于使用 MF 和 CF 的推荐系统的理论背景。总的来说,本章将涵盖以下主题:

  • 推荐系统

  • 矩阵分解与协同过滤方法

  • 开发基于 FM 的电影推荐系统

  • 常见问题解答(FAQ)

推荐系统

推荐技术本质上是信息代理,它们尝试预测用户可能感兴趣的物品,并向目标用户推荐最合适的物品。这些技术可以根据它们使用的信息来源进行分类。例如,用户特征(年龄、性别、收入、地点)、物品特征(关键词、类型)、用户-物品评分(显式评分、交易数据)以及其他对推荐过程有用的用户和物品信息。

因此,推荐系统,也称为推荐引擎RE),是信息过滤系统的一个子类,帮助预测基于用户提供的评分或偏好来推荐物品。近年来,推荐系统变得越来越流行。

推荐方法

开发推荐引擎(RE)以生成推荐列表有几种方法,例如,协同过滤、基于内容的过滤、基于知识的推荐或基于个性的方法。

协同过滤方法

通过使用 CF 方法,可以基于用户的过去行为构建推荐引擎(RE)。对于已消费的物品会给出数值评分。有时,推荐也可以基于其他用户做出的决策,这些用户也购买了相同的物品,并使用一些广泛应用的数据挖掘算法,如 Apriori 或 FP-growth。以下图示展示了不同推荐系统的一些概念:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/941dcf58-8778-4aa2-bb6d-caaf171215e3.png

不同推荐系统的比较视图

尽管这些是成功的推荐系统,基于 CF 的方法往往会面临以下三大问题:

  • 冷启动: 有时,当需要大量关于用户的数据来做出更准确的推荐系统时,它们可能会卡住。

  • 可扩展性: 使用一个包含数百万用户和商品的数据集进行推荐计算时,通常需要大量的计算能力。

  • 稀疏性: 这通常发生在众包数据集上,特别是当大量商品在主要电商网站上出售时。所有推荐数据集在某种意义上都是众包的。这是几乎所有推荐系统的普遍问题,尤其是当系统需要为大量商品提供服务,并且有足够多的用户时,并不仅限于电商网站。

在这种情况下,活跃用户可能只对所有销售的物品中的一个小子集进行评分,因此即使是最受欢迎的物品也只有很少的评分。因此,用户与物品的矩阵变得非常稀疏。换句话说,处理一个大规模的稀疏矩阵在计算上是非常具有挑战性的。

为了克服这些问题,某种类型的协同过滤算法采用矩阵分解,这是一个低秩矩阵近似技术。我们将在本章稍后看到一个示例。

基于内容的过滤方法

使用基于内容的过滤方法时,利用物品的一系列离散特征来推荐具有相似属性的其他物品。有时,这些方法基于物品的描述和用户偏好的个人资料。这些方法试图推荐与用户过去喜欢的物品相似的物品,或者是用户当前正在使用的物品。

基于内容的过滤方法的一个关键问题是系统是否能够根据用户对一个内容源的行为来学习其偏好,并将这些偏好应用到其他内容类型上。当这种类型的推荐引擎(RE)被部署时,就可以用来预测用户可能感兴趣的物品或物品的评分。

混合推荐系统

如你所见,使用协同过滤和基于内容的过滤方法各有优缺点。因此,为了克服这两种方法的局限性,近年来的趋势表明,混合方法可以更有效且准确。有时,像矩阵分解SVD)这样的因子化方法被用来增强其鲁棒性。

基于模型的协同过滤

协同过滤方法分为基于记忆的,如基于用户的算法和基于模型的协同过滤(推荐使用核映射)。在基于模型的协同过滤技术中,用户和产品通过一组小的因子来描述,这些因子也叫做潜在因子LFs)。然后使用这些潜在因子来预测缺失的条目。交替最小二乘法ALS)算法用于学习这些潜在因子。

与基于记忆的方法相比,基于模型的方法可以更好地处理原始矩阵的稀疏性。它还具有可扩展性、更快的速度,并且能够避免过拟合问题。然而,它缺乏灵活性和适应性,因为很难向模型中添加数据。现在,让我们来看一下协同过滤方法中的一个重要元素——效用矩阵。

效用矩阵

在基于协同过滤的推荐系统中,存在两类实体:用户和物品(物品指的是产品,如电影、游戏和歌曲)。作为用户,你可能对某些物品有偏好。因此,这些偏好必须从关于物品、用户或评分的数据中推导出来。这些数据通常表现为效用矩阵,例如用户-物品对。此类值可以表示用户对某个物品的偏好程度。

下表展示了一个示例效用矩阵,表示用户对电影的评分,评分范围为 1 到 5,其中 5 为最高评分。HP1HP2HP3分别是哈利·波特 IIIIII的缩写,TW代表暮光之城SW1SW2SW3分别代表星球大战 IIIIII。字母ABCD代表用户:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/a0963aa4-7115-4b60-834f-de08bdee83dc.png

效用矩阵(用户与电影的矩阵)

在用户-电影对中有许多空白项。这意味着用户尚未对这些电影进行评分,这增加了稀疏性。使用此矩阵的目标是预测效用矩阵中的空白项。假设我们好奇是否用户A会喜欢SW2。由于矩阵中没有太多数据,这个预测是困难的。

因此,关于电影的其他属性,如制片人、导演、主演,甚至是它们名字的相似性,都可以用来计算电影SW1SW2的相似度。这种相似性会引导我们得出结论,由于A不喜欢SW1,他们也不太可能喜欢SW2

然而,对于更大的数据集,这种方法可能不起作用。因此,当数据量更大时,我们可能会观察到评分SW1SW2的用户倾向于给它们相似的评分。最终,我们可以得出结论,A也会给SW2一个低评分,类似于ASW1的评分。然而,这种方法有一个严重的缺点,称为冷启动问题

协同过滤方法中的冷启动问题

冷启动问题这个词听起来有些滑稽,但正如其名字所示,它源自汽车。在推荐引擎中,冷启动问题只是意味着一种尚未达到最佳状态的情况,导致引擎无法提供最理想的结果。

在协同过滤方法中,推荐系统会识别与当前用户有相似偏好的用户,并推荐那些志同道合的用户喜欢的物品。由于冷启动问题,这种方法无法考虑没有人在社区中评分的物品。

使用基于协同过滤(CF)方法的推荐引擎根据用户的行为推荐每个物品。物品的用户行为越多,越容易判断哪些用户可能对该物品感兴趣,以及哪些其他物品与之相似。随着时间的推移,系统将能够提供越来越准确的推荐。当新的物品或用户被添加到用户-物品矩阵时,以下问题就会发生:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/09ebe6c2-f70c-4fbc-b0a0-1392572ad78f.png

用户与物品矩阵有时会导致冷启动问题

在这种情况下,推荐引擎对这个新用户或新物品的了解还不够。基于内容的过滤方法,类似于因子分解机(FM),是一种可以结合使用以缓解冷启动问题的方法。

推荐系统中的因子分解机

在实际生活中,大多数推荐问题假设我们拥有一个由(用户、物品和评分)元组构成的评分数据集。然而,在许多应用中,我们有大量的物品元数据(标签、类别和类型),这些可以用来做出更好的预测。

这就是使用因子分解机(FMs)与特征丰富数据集的好处之一,因为模型中可以自然地加入额外的特征,且可以使用维度参数建模高阶交互。

一些最近的研究类型展示了哪些特征丰富的数据集能够提供更好的预测:

  • Xiangnan He 和 Tat-Seng Chua,Neural Factorization Machines for Sparse Predictive Analytics。发表于 SIGIR '17 会议,东京新宿,日本,2017 年 8 月 07-11 日

  • Jun Xiao, Hao Ye, Xiangnan He, Hanwang Zhang, Fei Wu 和 Tat-Seng Chua(2017)。Attentional Factorization Machines: Learning the Weight of Feature Interactions via Attention Networks IJCAI,澳大利亚墨尔本,2017 年 8 月 19-25 日

这些论文解释了如何将现有数据转化为特征丰富的数据集,并且如何在该数据集上实现因子分解机(FMs)。因此,研究人员正试图使用因子分解机(FMs)来开发更准确和更强大的推荐引擎(REs)。在接下来的章节中,我们将开始开发基于因子分解机(FMs)的电影推荐项目。为此,我们将使用 Apache Spark 和 RankSys 库。

现有的推荐算法需要一个由*(用户、物品、评分)*元组构成的消费(产品)或评分(电影)数据集。这些类型的数据集主要被协同过滤(CF)算法的变种使用。协同过滤(CF)算法已被广泛采用,并且证明能够产生良好的结果。

然而,在许多情况下,我们也有大量的物品元数据(标签、类别和类型),这些可以用来做出更好的预测。不幸的是,协同过滤(CF)算法并未使用这些类型的元数据。

FM 可以利用这些特征丰富(元)数据集。FM 可以消耗这些额外的特征来建模更高阶的交互,并指定维度参数d。最重要的是,FM 也经过优化,可以处理大规模的稀疏数据集。因此,二阶 FM 模型就足够了,因为没有足够的信息来估计更复杂的交互:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/0bbeea54-1a2c-47f3-b5ef-9fb02d82ad45.png

一个示例训练数据集,表示一个个性化问题,特征向量 x 和目标 y。在这里,行代表电影,而列包括导演、演员和类型等信息

假设预测问题的数据集由设计矩阵X ∈ ℝ*n*(xp)描述。在前面的图中,i^(th)行,x[i] ∈ ℝ*^(p;)X中的一个案例,描述了一个包含p个实值变量的情况,且y[i]是第i个案例的预测目标。或者,我们可以将这个集合描述为一组元组(x,y)的集合,其中(再次)x ∈ ℝ^p*是特征向量,y是其对应的目标或标签。

换句话说,在图 7 中,每一行表示一个特征向量x[i]及其对应的目标y[i]。为了更容易理解,特征被分组为:活动用户(蓝色)、活动商品(红色)、相同用户评分的其他电影(橙色)、时间(月)(绿色)以及最后评分的电影(棕色)。

然后,FM 算法使用以下因子化的交互参数,建模p个输入变量在x中的所有嵌套交互(最多到 d*阶):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/c81dc120-f418-47ad-bc34-ac5df6ece7bc.png

在这个方程中,vs代表与每个变量(用户和商品)相关的k维潜在向量,而括号操作符表示内积。许多机器学习方法中都使用这种数据矩阵和特征向量的表示方式,例如线性回归或支持向量机(SVM)。

然而,如果你熟悉矩阵分解(MF)模型,那么前面的公式应该看起来很熟悉:它包含一个全局偏差,以及用户/商品特定的偏差,并且包括用户与商品的交互。现在,如果我们假设每个x(j)向量仅在位置ui处非零,我们得到经典的 MF 模型:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/1cc28cd9-dfb1-47b0-ae12-0c30b2d6f31d.png

然而,FM 也可以用于分类或回归,并且在处理大规模稀疏数据集时,比传统算法(如线性回归)计算效率更高。正是因为这个特点,FM 被广泛用于推荐系统:用户数和商品数通常非常大,尽管实际的推荐数量非常少(用户并不会对所有可用商品进行评分!)。

使用因子分解机(FM)开发电影推荐系统

在本项目中,我们将展示如何从 MovieLens 1M 数据集中进行排名预测。首先,我们将准备数据集。然后,我们将训练 FM 算法,最终预测电影的排名和评分。项目代码的结构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/a6c66d50-7932-41f9-bfbf-5ed054c9579f.png

电影评分和排名预测项目结构

总结来说,该项目的结构如下:

  • EDA: 该包用于对 MovieLens 1M 数据集进行探索性分析。

  • 工具、FMCore 和 DataUtils: 这些是核心的 FM 库。为了这个项目,我使用了(但进行了扩展)RankSys 库(可以查看 GitHub 仓库:github.com/RankSys/RankSys)。

  • 预处理: 这个包用于将 MovieLens 1M 数据集转换为 LibFM 格式。

  • 预测: 这个包用于电影评分和排名预测。

  • GraphUtil: 该包用于在迭代过程中可视化一些性能指标。

我们将一步步讲解所有这些包。不过,了解数据集是必须的。

数据集描述和探索性分析

MovieLens 1M 小型数据集是从 MovieLens 网站下载(并已获得必要的许可)并使用的,网址为:grouplens.org/datasets/movielens/。我由衷感谢 F. Maxwell Harper 和 Joseph A. Konstan 提供的这个数据集。该数据集已发表于 MovieLens 数据集:历史与背景,ACM 交互智能系统事务(TiiS)5(4),文章 19(2015 年 12 月),共 19 页。

数据集包含三个文件:movies.datratings.datusers.dat,分别与电影、评分和用户相关。文件中包含 1,000,209 个匿名评分,涵盖约 3,900 部电影,评分由 6,040 名于 2000 年加入 MovieLens 的用户提供。所有评分都存储在 ratings.dat 文件中,格式如下:

UserID::MovieID::Rating::Timestamp

描述如下:

  • 用户 ID:范围在 1 到 6,040 之间

  • 电影 ID:范围在 1 到 3,952 之间

  • 评分:这些评分采用 5 星制

  • 时间戳:这是以秒为单位表示的

请注意,每个用户至少评价了 20 部电影。电影信息则保存在 movies.dat 文件中,格式如下:

MovieID::Title::Genres

描述如下:

  • 标题:这些与 IMDb 提供的标题相同(包括上映年份)

  • 类别:这些是用逗号(,)分隔的,每部电影被分类为动作、冒险、动画、儿童、喜剧、犯罪、剧情、战争、纪录片、奇幻、黑色电影、恐怖、音乐、悬疑、浪漫、科幻、惊悚和西部

最后,用户信息保存在 users.dat 文件中,格式如下:

UserID::Gender::Age::Occupation::Zip-code 

所有的个人信息是用户自愿提供的,且没有经过准确性检查。只有那些提供了部分个人信息的用户才会被包含在此数据集中。M 代表男性,F 代表女性,性别由此标识。年龄选择以下范围:

  • 1: 未满 18 岁

  • 18: 18-24

  • 25: 25-34

  • 35: 35-44

  • 45: 45-49

  • 50: 50-55

  • 56: 56 岁以上

职业从以下选项中选择:

  • 0: 其他,或未指定

  • 1: 学术/教育工作者

  • 2: 艺术家

  • 3: 文员/行政

  • 4: 大学/研究生学生

  • 5: 客服

  • 6: 医生/医疗保健

  • 7: 高管/经理

  • 8: 农民

  • 9: 家庭主妇

  • 10: K-12 学生

  • 11: 律师

  • 12: 程序员

  • 13: 退休

  • 14: 销售/市场营销

  • 15: 科学家

  • 16: 自雇

  • 17: 技术员/工程师

  • 18: 工匠/技工

  • 19: 失业

  • 20: 作家

现在我们了解了数据集,接下来可以开始进行探索性分析。首先,我们将创建一个 Spark 会话,作为 Spark 程序的入口:

SparkSession spark = new Builder()
                  .master("local[*]")
                  .config("spark.sql.warehouse.dir", "temp/")// change accordingly
                  .appName("MovieRecommendation")
                  .getOrCreate();

然后,我们将加载并解析rating.dat文件,进行一些探索性分析。以下代码行应该返回数据框 rating:

// Read RatingsFile
Dataset<Row> df1 = spark.read()
                .format("com.databricks.spark.csv")
                .option("inferSchemea", "true")
                .option("header", "true")
                .load(ratingsFile);

Dataset<Row> ratingsDF = df1.select(df1.col("userId"), df1.col("movieId"),
                df1.col("rating"), df1.col("timestamp"));
ratingsDF.show(10);

输出结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/9aea1ec0-6fe1-45bf-8814-da645acbe6da.png

接下来,我们将加载movies.dat并准备电影数据框:

// Read MoviesFile
Dataset<Row> df2 = spark.read()
                .format("com.databricks.spark.csv")
                .option("inferSchema", "true")
                .option("header", "true")
                .load(movieFile);

Dataset<Row> moviesDF = df2.select(df2.col("movieId"), df2.col("title"), df2.col("genres"));
moviesDF.show(10);

输出结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/8f533e03-9994-428f-b125-bc49b54234ef.png

然后,我们将注册两个数据框作为临时表,以便更容易进行查询。要注册这两个数据集,以下代码行需要被使用:

ratingsDF.createOrReplaceTempView("ratings");
moviesDF.createOrReplaceTempView("movies");

请注意,这将通过在内存中创建一个临时视图作为表,帮助加快内存查询的速度。然后,我们将选择探索一些与评分和电影相关的统计信息:

long numberOfRatings = ratingsDF.count();
long numberOfUsers = ratingsDF.select(ratingsDF.col("userId")).distinct().count();
long numberOfMovies = ratingsDF.select(ratingsDF.col("movieId")).distinct().count();

String print = String.*format*("Got %d ratings from %d users on %d movies.", numberOfRatings, numberOfUsers, numberOfMovies);
System.*out*.println(print);

输出结果如下:

Got 100004 ratings from 671 users on 9066 movies.

现在,让我们获取最大和最小评分以及评分过电影的用户数量。不过,你需要对我们刚刚在内存中创建的评分表执行一个 SQL 查询。在这里执行查询非常简单,类似于从 MySQL 数据库或关系型数据库管理系统(RDBMS)中进行查询。

然而,如果你不熟悉基于 SQL 的查询,建议你查看 SQL 查询规范,了解如何使用SELECT从特定表中进行选择,如何使用ORDER进行排序,如何使用JOIN关键字执行连接操作。

好吧,如果你知道 SQL 查询,你应该使用如下复杂的 SQL 查询获取一个新的数据集:

// Get the max, min ratings along with the count of users who have rated a movie.
Dataset<Row> sqlDF = spark.sql(
                "SELECT movies.title, movierates.maxr, movierates.minr, movierates.cntu "
                        + "FROM (SELECT "
                        + "ratings.movieId, MAX(ratings.rating) AS maxr,"
                        + "MIN(ratings.rating) AS minr, COUNT(distinct userId) AS cntu "
                        + "FROM ratings "
                        + "GROUP BY ratings.movieId) movierates "
                        + "JOIN movies ON movierates.movieId=movies.movieId "
                        + "ORDER BY movierates.cntu DESC");
sqlDF.show(10);

输出结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/4bc62580-9bc3-4f30-846b-b540e7625346.png

现在,为了深入了解,我们需要更多地了解用户及其评分。让我们找出前 10 个最活跃的用户,以及他们评分的电影次数:

// Top 10 active users and how many times they rated a movie.
Dataset<Row> mostActiveUsersSchemaRDD = spark.sql(
                "SELECT ratings.userId, count(*) AS ct "
                        + "FROM ratings "
                        + "GROUP BY ratings.userId "
                        + "ORDER BY ct DESC LIMIT 10");
mostActiveUsersSchemaRDD.show(10);

输出结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/5cbda7d6-4e4c-41d8-9dae-4d925e40931c.png

最后,让我们看一下某个特定用户,找出例如用户 668 评分高于 4 的电影:

// Movies that user 668 rated higher than 4
Dataset<Row> userRating = spark.sql(
                "SELECT ratings.userId, ratings.movieId, ratings.rating, movies.title "
                        + "FROM ratings JOIN movies "
                        + "ON movies.movieId=ratings.movieId "
                        + "WHERE ratings.userId=668 AND ratings.rating > 4");
userRating.show(10);

输出结果如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/cbdb75b4-cb1c-493d-ac42-d3425b5250ae.png

电影评分预测

首先,我们使用 FM 算法进行评分预测,该算法通过PointWiseGradientDescent学习。我们从数据预处理和转换为 LibFM 格式开始。要运行此评分预测,请按以下执行顺序:

  1. 首先,执行MovieLensFormaterWithMetaData.java;生成LibFM格式的MovieLens数据。

  2. 然后,执行SplitDataWithMetaData.java来准备训练集、测试集和验证集。

  3. 最后,执行MovieRatingPrediction.java,这是主类。

将数据集转换为 LibFM 格式

我们要重用的基于 FM 的模型只能消耗 LibFM 格式的训练数据,这与 LibSVM 格式差不多。因此,首先我们必须将 MovieLens 1M 数据集格式化,以便训练数据集包含用户、电影和现有评分信息。

LibFM 格式类似于 LibSVM 格式,但有一些基本的区别。更多信息,感兴趣的读者可以查看www.libfm.org/libfm-1.42.manual.pdf

同时,新特性将由用户信息和电影信息生成。首先,我们将定义输入(这将根据用户、电影和评分更新)和输出文件路径,如下所示:

//MovieLensFormaterWithMetaData.java
private static String *inputfilepath**;* private static String *outputfilepath*;

然后,我们定义数据路径和输出文件夹,用于保存生成的 LibFM 格式数据:

String foldername = "ml-1m";
String outFolder = "outFolder";

接着,我们定义目标列,这是 FM 模型要预测的内容。此外,我们还删除了时间戳列:

private static int *targetcolumn* = 0;
private static String *deletecolumns* = "3";

然后,我们设置分隔符为::并进行偏移:

private static String *separator* = "::";
private static int *offset* = 0;

接着,我们读取并解析用户数据(即users.dat),并为用户的类型、年龄和职业信息创建三个Map<Integer, String>

Set<Integer> deletecolumnsset = new HashSet<Integer>();
Map<String, Integer> valueidmap = new HashMap<String, Integer>(); 

*targetcolumn* = 2; // movielens format
String[] deletecolumnarr = *deletecolumns*.split(";"); 

for(String deletecolumn : deletecolumnarr) { 
          deletecolumnsset.add(Integer.*parseInt*(deletecolumn));
       }
*inputfilepath* = foldername + File.*separator* + "users.dat"; 
Reader fr = new FileReader(*inputfilepath*); 
BufferedReader br = new BufferedReader(fr); 

Map<Integer, String> usergenemap = new HashMap<Integer, String>();
Map<Integer, String> useragemap = new HashMap<Integer, String>();
Map<Integer, String> useroccupationmap = new HashMap<Integer, String>(); 

String line;
while (br.ready()) {
             line = br.readLine();
             String[] arr = line.split(*separator*); 
             usergenemap.put(Integer.*parseInt*(arr[0]), arr[1]); 
             useragemap.put(Integer.*parseInt*(arr[0]), arr[2]);
             useroccupationmap.put(Integer.*parseInt*(arr[0]), arr[3]);
          } 
br.close();
fr.close();

然后,我们解析电影数据集,创建一个Map<Integer, String>来存储电影信息:

*inputfilepath* = foldername + File.*separator* + "movies.dat"; 
fr = new FileReader(*inputfilepath*); 
br = new BufferedReader(fr);

Map<Integer, String> moviemap = new HashMap<Integer, String>();

while (br.ready()) {
              line = br.readLine(); 
              String[] arr = line.split(*separator*); 
               moviemap.put(Integer.*parseInt*(arr[0]), arr[2]); 
}
br.close();
fr.close();

然后,我们解析评分数据集,创建一个Map<Integer, String>来存储现有的评分。此外,我们定义了 LibFM 格式下将保存评分数据的输出文件名:

inputfilepath = foldername + File.separator + "ratings.dat";
outputfilepath = outFolder + File.separator + "ratings.libfm";
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(outputfilepath)));

        fr = new FileReader(inputfilepath);
        br = new BufferedReader(fr);

        while(br.ready()) {
            line = br.readLine();
            String[] arr = line.split(separator);
            StringBuilder sb = new StringBuilder();
            sb.append(arr[targetcolumn]);

            int columnidx = 0;
            int userid = Integer.parseInt(arr[0]);
            int movieid = Integer.parseInt(arr[1]);

            for(int i = 0; i < arr.length; i++) {
                if(i != targetcolumn && !deletecolumnsset.contains(i)) {
                    String useroritemid = Integer.toString(columnidx) + " " + arr[i];

                    if(!valueidmap.containsKey(useroritemid)) {
                        valueidmap.put(useroritemid, offset++);
                    }

                    sb.append(" ");
                    sb.append(valueidmap.get(useroritemid));
                    sb.append(":1");

                    columnidx++;
                }

然后,我们开始添加一些属性,如性别信息、年龄、职业和电影类别信息:

// Add attributes
String gender = usergenemap.get(userid);
String attributeid = "The gender information " + gender;

 if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String age = useragemap.get(userid);
            attributeid = "The age information " + age;

            if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String occupation = useroccupationmap.get(userid);
            attributeid = "The occupation information " + occupation;

            if(!valueidmap.containsKey(attributeid)) {
                valueidmap.put(attributeid, offset++);
            }

            sb.append(" ");
            sb.append(valueidmap.get(attributeid));
            sb.append(":1");

            String movieclassdesc = moviemap.get(movieid);
            String[] movieclassarr = movieclassdesc.split("\\|");

            for(String movieclass : movieclassarr) {
                attributeid = "The movie class information " + movieclass;
                if(!valueidmap.containsKey(attributeid)) {
                    valueidmap.put(attributeid, offset++);
                }

                sb.append(" ");
                sb.append(valueidmap.get(attributeid));
                sb.append(":1");
}

在前面的代码块中,:1代表用户为哪个电影提供了评分。最后,我们添加元数据,useridmovieid

//add metadata information, userid and movieid
sb.append("#");
sb.append(userid);
sb.append(" "+movieid);
writer.write(sb.toString());
writer.newLine();

现在,生成的评分数据集(执行MovieLensFormaterWithMetaData.java后)将以 LibFM 格式保存在formatted_data目录下,文件名为ratings.libfm,其结构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/260b022e-5277-4ad9-ba90-c0104a8266db.png

训练集和测试集的准备

现在我们已经了解了如何转换评分、电影和元数据,接下来我们可以开始从 LibFM 格式的数据中创建训练集、测试集和验证集。首先,我们设置将要使用的 LibFM 文件的路径,如下所示:

//SplitDataWithMetaData.java
private static String *ratinglibFM* = *formattedDataPath* + "/" + "ratings.libfm"; // input
private static String *ratinglibFM_train* = *formattedDataPath* + "/" + "ratings_train.libfm"; // for traning
private static String *ratinglibFM_test* = *formattedDataPath* + "/" + "ratings_test.libfm"; // for testing
private static String *ratinglibFM_test_meta* = *formattedDataPath* +"/"+"ratings_test.libfm.meta";// metadata
private static String *ratinglibFM_valid* = *formattedDataPath* + "/" + "ratings_valid.libfm"; // validation

然后,我们展示输出目录,用于写入分割后的训练集、验证集和测试集:

private static String *formattedDataPath* = "outFolder";

接着,我们实例化一个BufferedWriter,用于写入分割后的文件:

Reader fr = new FileReader(ratinglibFM);
Random ra = new Random();

BufferedWriter trainwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_train)));

BufferedWriter testwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_test)));

BufferedWriter testmetawrite = new BufferedWriter(new OutputStreamWriter(new                       FileOutputStream(ratinglibFM_test_meta)));   

BufferedWriter validwrite = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(ratinglibFM_valid)));

BufferedReader br = new BufferedReader(fr);
String line = null;
int testline = 0;

while(br.ready()) {
       line = br.readLine();
       String[] arr = line.split("#");
       String info = arr[0];

       double dvalue = ra.nextDouble();
       if(dvalue>0.9)
            {
             validwrite.write(info);
             validwrite.newLine();
            }

       else if(dvalue <= 0.9 && dvalue>0.1) {
                trainwrite.write(info);
                trainwrite.newLine();
         } else {
                testwrite.write(info);
                testwrite.newLine();
           if(arr.length==2)
                {
                testmetawrite.write(arr[1] + " " + testline);
                testmetawrite.newLine();
                testline++;
            }
         }
  }

最后,我们关闭文件指针以释放资源:

br.close();
fr.close();

trainwrite.flush();
trainwrite.close();

testwrite.flush();
testwrite.close();

validwrite.flush();
validwrite.close();

testmetawrite.flush();
testmetawrite.close();

现在,结果评分数据集(执行SplitDataWithMetaData.java后)将以 LibFM 格式保存在formatted_data目录中,格式与 LibSVM 类似:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/36100945-4ad5-4e4b-b091-e80013f3884c.png

最后,目录(即formatted_data)将包含以下文件:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/11798569-110f-47d7-80ab-8e97f2a8aa58.png

太棒了!现在我们的数据集已经准备好,我们可以使用 FM 算法开始进行电影评分预测了。

电影评分预测

现在,所有训练、验证和评估所需的数据集都已准备就绪,我们可以开始训练 FM 模型。我们首先展示训练数据的文件名:

final String trainFile = *formattedDataPath*+ "/" + "ratings_train.libfm";

然后,我们设置测试数据文件路径:

final String testFile = *formattedDataPath*+ "/" + "ratings_test.libfm";

然后,我们设置测试元数据文件路径:

final String testMetaFile = *formattedDataPath*+ "/" + "ratings_test.libfm.meta";

然后,设置最终预测输出文件的文件名:

final String outputFile = *formattedDataPath*+ "/" + "predict_output.txt";

然后,我们设置日志、指标、时间等每次迭代的文件写入路径(但不用担心,我们也会看到图形形式的结果):

final String rLog = *outPut* + "/" + "metrics_logs.txt";

然后,我们设置 k0、k1 和 k2 的维度,使得 k0 用于偏置,k1 用于单向交互,k2 用于双向交互的维度:

final String dimension = "1,1,8"; // tunable parameters

我们将迭代训练 100 次迭代次数:

final String iterations = "100"; // tunable parameter

接着,我们为 SGD 设置学习率——优化器试图最小化误差的速率:

final String learnRate = "0.01"; // tunable and learnable parameter

现在优化器已经知道了学习率,接下来的重要参数是设置正则化参数,以防止训练时过拟合。

基于 Java 的 FM 库需要三种正则化:偏置、单向和双向正则化。因此,FM 库接受的格式是 r0、r1、r2。这里,r0 是偏置正则化,r1 是单向正则化,r2 是双向正则化:

final String regularization = "0,0,0.1";

然后,我们初始化用于初始化双向因子的标准差:

final String stdDeviation = "0.1";

然后,我们使用LibSVMDataProvider()类加载训练集和测试集:

System.*out*.println("Loading train...t");
DataProvider train = new LibSVMDataProvider();
Properties trainproperties = new Properties();

trainproperties.put(Constants.*FILENAME*, trainFile);
train.load(trainproperties,false);

System.*out*.println("Loading test... t");
DataProvider test = new LibSVMDataProvider();
Properties testproperties = new Properties();

testproperties.put(Constants.*FILENAME*, testFile);
test.load(testproperties,false);

一旦训练集和测试集加载完成,我们开始创建用户-物品表(即主表):

int num_all_attribute = Math.*max*(train.getFeaturenumber(), test.getFeaturenumber());
DataMetaInfo meta = new DataMetaInfo(num_all_attribute);
meta.debug();
Debug.*openConsole*();

然后,我们实例化因式分解机,在开始训练之前:

FmModel fm = new FmModel();

然后,使用init()方法初始化实例化和训练以下 FM 模型所需的参数:

public FmModel()
    {
        num_factor = 0;
        initmean = 0;
        initstdev = 0.01;
        reg0 = 0.0;
        regw = 0.0;
        regv = 0.0; 
        k0 = true;
        k1 = true;
    }

init()方法的签名如下:

public void init()
    {
        w0 = 0;
        w = new double[num_attribute];
        v = new DataPointMatrix(num_factor, num_attribute);
        Arrays.fill(w, 0);
        v.init(initmean, initstdev);
        m_sum = new double[num_factor];
        m_sum_sqr = new double[num_factor];
    }

然后,我们设置主类的属性数和标准差:

fm.num_attribute = num_all_attribute;
fm.initstdev = Double.*parseDouble*(stdDeviation);

然后,我们设置因式分解的维度数量。在我们的例子中,我们有三种交互——用户、电影和评分:

Integer[] dim = *getIntegerValues*(dimension);
assert (dim.length == 3);
fm.k0 = dim[0] != 0;
fm.k1 = dim[1] != 0;
fm.num_factor = dim[2];

前面的值实际上是通过getIntegerValues()方法解析的,该方法接受维度作为字符串并使用,进行拆分。

最后,它只返回用于模型进行交互的维度的整数值。使用以下签名来实现:

static public Integer[] getIntegerValues(String parameter) {
        Integer[] result = null;
        String[] strresult = Util.tokenize(parameter, ",");
        if(strresult!=null && strresult.length>0) {
            result = new Integer[strresult.length];
            for(int i=0;i<strresult.length;i++) {
                result[i] = Integer.parseInt(strresult[i]);
            }
        }
        return result;
    }

然后,我们将学习方法设置为随机梯度下降SGD):

FmLearn fml = new FmLearnSgdElement();
((FmLearnSgd) fml).num_iter = Integer.*parseInt*(iterations);

fml.fm = fm;
fml.max_target = train.getMaxtarget();
fml.min_target = train.getMintarget();
fml.meta = meta;

接着,我们定义要执行的任务类型。在我们的例子中,它是回归。然而,我们将使用 TASK_CLASSIFICATION 进行分类:

fml.task = TaskType.*TASK_REGRESSION*

然后,我们设置正则化:

Double[] reg = *getDoubleValues*(regularization);
assert ((reg.length == 3)); // should meet 3 way regularization

fm.reg0 = reg[0];
fm.regw = reg[1];
fm.regv = reg[2];

然后,关于学习率,我们必须设置每层的学习率(单独设置),这与 DL4J 库不同:

FmLearnSgd fmlsgd = (FmLearnSgd) (fml);

if (fmlsgd != null) {
        Double[] lr = *getDoubleValues*(learnRate);
        assert (lr.length == 1);
        fmlsgd.learn_rate = lr[0];
        Arrays.*fill*(fmlsgd.learn_rates, lr[0]);
}

前面的值实际上是通过 getDoubleValues() 方法解析的,该方法接受一个字符串形式的学习率,并使用 , 进行分割。最后,它返回一个单一的学习率值供模型使用。此方法的签名如下:

static public Double[] getDoubleValues(String parameter) {
        Double[] result;
        String[] strresult = Util.tokenize(parameter, ",");
        if(strresult!=null && strresult.length>0) {
            result = new Double[strresult.length];
            for(int i=0; i<strresult.length; i++) {
                result[i] = Double.parseDouble(strresult[i]);
            }
        }
        else {
            result = new Double[0];
        }
        return result;
    }

现在所有的超参数都已经设置好,我们准备开始训练了。与 DL4J FM 不同的是,它提供了一个 learn() 方法来学习模型:

fml.learn(train, test);

learn() 方法是一个抽象方法,接收训练集和测试集:

//FmLearn.java
public abstract void learn(DataProvider train, DataProvider test) throws Exception;

learn() 方法的具体实现需要同时传入训练集和测试集。然后,它会打乱训练集,以避免训练中的偏差。接着,使用 predict() 方法进行预测操作,基于我们一开始定义的任务类型(在我们这个例子中是回归)。

最后,它会在测试集上评估模型,并计算训练集和测试集的均方误差(MSE)。该方法的实际实现如下:

//FmLearnSgdElement.java
public void learn(DataProvider train, DataProvider test)  throws Exception{
        super.learn(train, test);
        List<Double> iterationList=new ArrayList<Double>();
        List<Double> trainList=new ArrayList<Double>();
        List<Double> testList=new ArrayList<Double>();

        // SGD
        for(int i = 0; i < num_iter; i++) {
            try
            {
                double iteration_time = Util.getusertime();
                train.shuffle();
                for(train.getData().begin(); !train.getData().end(); train.getData().next()) {
                    double p = fm.predict(train.getData().getRow(), sum, sum_sqr);
                    double mult = 0;

                    if(task == TaskType.TASK_REGRESSION) {
                        p = Math.min(max_target, p);
                        p = Math.max(min_target, p);
                        mult = -(train.getTarget()[train.getData().getRowIndex()]-p);
                    } else if(task == TaskType.TASK_CLASSIFICATION) {
                        mult = -train.getTarget()[train.getData().getRowIndex()]*
                                (1.0-1.0/(1.0+Math.exp(-train.getTarget()[train.getData()
                                .getRowIndex()]*p)));
                    }                
                    SGD(train.getData().getRow(), mult, sum);                    
                }                
                iteration_time = (Util.getusertime() - iteration_time);
                double rmse_train = evaluate(train);
                double rmse_test = evaluate(test);
                iterationList.add((double)i);
                testList.add(rmse_test);
                trainList.add(rmse_train);

                String print = String.format("#Iterations=%2d::  
                               Train_RMSE=%-10.5f  Test_RMSE=%-10.5f", i, rmse_train, rmse_test);
                Debug.println(print);
                if(log != null) {
                    log.log("rmse_train", rmse_train);
                    log.log("time_learn", iteration_time);
                    log.newLine();
                }
            }
            catch(Exception e)
            {
                throw new JlibfmRuntimeException(e);// Exception library for Java FM
            }
        }    
        PlotUtil_Rating.plot(convertobjectArraytoDouble(iterationList.toArray()),
                convertobjectArraytoDouble(testList.toArray()),
                convertobjectArraytoDouble(trainList.toArray()));

    }

在前面的代码块中,FM 模型通过考虑三元交互等方式进行预测操作,类似于其他回归算法,并将预测结果计算为概率:

// FmModel.java, we create a sparse matrix 
public double predict(SparseRow x, double[] sum, double[] sum_sqr)
    {
        double result = 0;
        if(k0) {    
            result += w0;
        }
        if(k1) {
            for(int i = 0; i < x.getSize(); i++) {
                result += w[x.getData()[i].getId()] * x.getData()[i].getValue();
            }
        }
        for(int f = 0; f < num_factor; f++) {
            sum[f] = 0;
            sum_sqr[f] = 0;
            for(int i = 0; i < x.getSize(); i++) {
                double d = v.get(f,x.getData()[i].getId()) * x.getData()[i].getValue();
                sum[f] = sum[f]+d;
                sum_sqr[f] = sum_sqr[f]+d*d;
            }
            result += 0.5 * (sum[f]*sum[f] - sum_sqr[f]);
        }

        return result;
 }

然而,最后,使用 PlotUtil_Rating 类中的 plot() 方法对每次迭代的训练和测试 MSE 进行可视化。我们稍后会讨论这个类。

此外,我们还初始化了日志记录,以便在控制台上打印计算的结果和进度:

System.*out*.println("logging to " + rLog);
RLog rlog = new RLog(rLog);
fml.log = rlog;
fml.init();
rlog.init();
fm.debug();
fml.debug();

最后,我们在测试集上评估模型。由于我们的任务是回归任务,我们计算每次迭代的回归指标,例如 RMSE:

String print = String.*format*("#Iterations=%s:: Train_RMSE=%-10.5f Test_RMSE=%-10.5f", iterations, fml.evaluate(train), fml.evaluate(test));
System.*out*.println(print);
>>> Loading train...
 Loading test...
 #attr=9794 #groups=1
 #attr_in_group[0]=9794
 logging to outFolder/output.txt
 num_attributes=9794
 use w0=true
 use w1=true
 dim v =8
 reg_w0=0.0
 reg_w=0.0
 reg_v=0.0
 init ~ N(0.0,0.1)
 num_iter=100
 task=TASK_REGRESSION
 min_target=1.0
 max_target=5.0
 learnrate=0.01
 learnrates=0.01,0.01,0.01
 #iterations=100
 #Iterations= 0:: Train_RMSE=0.92469 Test_RMSE=0.93231
 #Iterations= 1:: Train_RMSE=0.91460 Test_RMSE=0.92358
 #Iterations= 2:: Train_RMSE=0.91595 Test_RMSE=0.92535
 #Iterations= 3:: Train_RMSE=0.91238 Test_RMSE=0.92313
 ...
 #Iterations=98:: Train_RMSE=0.84275 Test_RMSE=0.88206
 #Iterations=99:: Train_RMSE=0.84068 Test_RMSE=0.87832

最后,我们将预测结果及所有相关的指标保存在一个文件中:

// prediction at the end
String print = String.format("#Iterations=%s::  Train_RMSE=%-10.5f  Test_RMSE=%-10.5f", iterations, fml.evaluate(train), fml.evaluate(test));
System.out.println(print);

// save prediction
Map<Integer, String> ratingsMetaData = new HashMap<>();
if(Files.exists(Paths.get(testMetaFile))) {
            BufferedReader bufferedReader = new BufferedReader(new FileReader(testMetaFile));
            String line;

            while((line = bufferedReader.readLine()) != null) {
                String[] splitLine = line.split("\\s+");
                if(splitLine.length > 0) {
                    Integer indexKey = Integer.parseInt(splitLine[2]);
                    String userIdmovieIdValue = splitLine[0] + " " +  splitLine[1];
                    ratingsMetaData.put(indexKey, userIdmovieIdValue);
                }
            }
        }

double[] pred = new double[test.getRownumber()];
fml.predict(test, pred);
Util.save(ratingsMetaData, pred, outputFile);

String FILENAME = Constants.FILENAME;
// Save the trained FM model 
fmlsgd.saveModel(FILENAME);

前面的代码块将生成两个文件,分别是 predict_output.txtmetrics_logs.txt,用于分别写入预测结果和日志。例如,predicted_output.txt 文件中的一个样本显示第二列是电影 ID,第三列是预测评分(满分 5.0),如下所示:

1 3408 4.40
 1 2797 4.19
 1 720 4.36
 1 1207 4.66
 2 1537 3.92
 2 1792 3.39
 2 1687 3.32
 2 3107 3.55
 2 3108 3.46
 2 3255 3.65

另一方面,metrics_logs.txt 显示了包括 RMSE、MAE 和日志等指标,如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/1133cb50-c9fd-41d2-9223-a7f4e5cd5a08.png

然而,由于仅凭这些数值难以理解训练状态和预测效果,我决定将它们绘制成图。以下图展示了每次迭代的训练和测试阶段的 MSE:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/7f56c119-7126-4ba2-8020-792ced3044aa.png

每次迭代的训练和测试 MSE(100 次迭代)

前述图表显示,训练误差和测试误差一致,这意味着 FM 模型没有过拟合。该图表还显示,错误数量仍然很高。然后,我将训练迭代了 1,000 次,发现错误有所减少,具体内容如下图所示:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/c151fd9e-375d-4f6d-8405-c803b312bbb5.png

每次迭代的训练和测试均方误差(MSE),最多迭代 1,000 次

现在,为了绘制前述图表,我在PlotUtil_Rating.java类中编写了一个plot()方法,使用JFreeChart库绘制训练和测试误差/迭代:

public static void plot(double[] iterationArray, double[] testArray, double[] trainArray) {
    final XYSeriesCollection dataSet = new XYSeriesCollection();
    addSeries(dataSet, iterationArray, testArray, "Test MSE per iteration");
    addSeries(dataSet, iterationArray, trainArray, "Training MSE per iteration");

    final JFreeChart chart = ChartFactory.createXYLineChart(
            "Training and Test error/iteration (1000 iterations)", // chart title
            "Iteration", // x axis label
            "MSE", // y axis label
            dataSet, // data
            PlotOrientation.VERTICAL,
            true, // include legend
            true, // tooltips
            false // urls
    );

    final ChartPanel panel = new ChartPanel(chart);
    final JFrame f = new JFrame();
    f.add(panel);
    f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
    f.pack();
    f.setVisible(true);
}

XYSeries类中的addSeries()方法用于为图表添加系列:

private static void addSeries (final XYSeriesCollection dataSet, double[] x, double[] y, final String label){
    final XYSeries s = new XYSeries(label);
    for(int j = 0; j < x.length; j++ ) s.add(x[j], y[j]);
    dataSet.addSeries(s);
}

哪个更有意义;– 排名还是评分?

在开发电影推荐系统时,评分预测还是排名预测更具逻辑性?如果每个用户的评分量足够高,分解用户-产品矩阵是最好的方法。在我看来,然而,如果数据集过于稀疏,预测可能会非常不准确。

知道这一点后,我开始探索 RankSys 库,并发现其中一位贡献者认为排名更具逻辑性,尽管他没有提供任何解释。后来,我与一些推荐系统的开发者和研究人员交流,了解到他可能指的是,排名由于评分和项目数量之间的差距,对预测误差不那么敏感。原因是排名保留了层次结构,而不依赖于绝对评分。

基于此理解,后来我决定向排名预测迈出一步。为此,我编写了一个单独的类RankingPrediction.java,用于预测测试集中的每个用户的电影排名,其结构如下:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/31a53fdf-8eae-44d1-845a-c9ffd79f05f8.png

电影排名预测子项目结构

该类有三个方法,具体如下:

  • createIndexFromFile():此方法用于根据传入方法参数的文件创建索引。

  • generateTrainAndTestDataSet():此方法用于将数据分为训练集和测试集,这是评估数据挖掘模型的重要部分。

  • main():此方法用于创建项目和用户的索引,并用于以下操作:

    • 一组用户的索引

    • 一组项目的索引

    • 存储由FastUserIndexFastItemIndex提供的用户和项目的偏好/评分

    • 创建一个推荐接口,将由FMRecommender使用

    • 使用一个因式分解机,该机器使用RMSE-like 损失,并均衡地采样负实例

首先,我们设置输入数据文件的路径:

final String folderPath = "ml-1m";
final String indexPath = "index";
final String userFileName = "users.dat";
final String moviesFileName = "movies.dat";
final String ratingsFileName = "ratings.dat";
final String encodingUTF8 = "UTF-8";

final String userDatPath = folderPath + "/" + userFileName;
final String movieDatPath = folderPath + "/" + moviesFileName;

接下来,我们设置之前提到的用户和电影索引路径:

final String indexPath = "index";
final String userIndexPath = indexPath + "/" + "userIndex";
final String movieIndexPath = indexPath + "/" + "movieIndex";

然后,我们设置结果文件的路径,训练集和测试集将在该路径生成:

String trainDataPath = indexPath + "/ratings_train";
String testDataPath = indexPath + "/ratings_test";
final String ratingsDatPath = folderPath + "/" + ratingsFileName;

然后,我们为users.dat文件中的所有用户创建用户索引。在这里,用户在内部由从 0(包括)到索引用户数量(不包括)的数字索引表示:

FastUserIndex<Long> userIndex = SimpleFastUserIndex.*load*(UsersReader.*read*(userIndexPath, *lp*));

在前面的代码行中,我们使用了 RankSys 库中的SimpleFastUserIndex类,它帮助我们创建了一个由名为IdxIndex的双向映射支持的FastUserIndex的简单实现。

然后,我们为movies.dat文件中的所有项目创建项目索引。这为一组项目创建了索引。在这里,项目在内部由从 0(包括)到索引项目数量(不包括)的数字索引表示:

FastItemIndex<Long> itemIndex = SimpleFastItemIndex.*load*(ItemsReader.*read*(movieIndexPath, *lp*));

在前面的代码行中,我们使用了 RankSys 库中的SimpleFastItemIndex类,它帮助我们创建了一个由名为IdxIndex的双向映射支持的FastItemIndex的简单实现。然后,我们存储了由FastUserIndexFastItemIndex提供的用户和项目的偏好/评分:

FastPreferenceData<Long, Long> trainData = SimpleFastPreferenceData.*load*(SimpleRatingPreferencesReader.*get*().read(trainDataPath, *lp*, *lp*), userIndex, itemIndex);

FastPreferenceData<Long, Long> testData = SimpleFastPreferenceData.*load*(SimpleRatingPreferencesReader.*get*().read(testDataPath, *lp*, *lp*), userIndex, itemIndex);

然后,我们调用这两个方法来创建用户和项目索引:

if (!Files.*exists*(Paths.*get*(userIndexPath))) {
     *createIndexFromFile*(userDatPath, encodingUTF8, userIndexPath);
}

if (!Files.*exists*(Paths.*get*(movieIndexPath))) {
 *createIndexFromFile*(movieDatPath, encodingUTF8, movieIndexPath);
}

在前面的 if 语句中,我们使用createIndexFromFile()方法从文件中生成了索引,该方法如下所示:

static void createIndexFromFile(String fileReadPath, String encodings, String fileWritePath) throws IOException {
        BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(new FileInputStream(
                        fileReadPath), Charset.forName(encodings)));
        BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(fileWritePath)));

        String line;
        while((line = bufferedReader.readLine()) != null) {
            StringBuilder builder = new StringBuilder();
            String[] lineArray = line.split("::");
            builder.append(lineArray[0]);
            writer.write(builder.toString());
            writer.newLine();
        }

        writer.flush();

        bufferedReader.close();
        writer.close();
    }

一旦索引文件生成,我们就开始生成训练集和测试集,如下所示:

if ( !Files.*exists*(Paths.*get*(trainDataPath))) {
 *generateTrainAndTestDataSet*(ratingsDatPath, trainDataPath, testDataPath);
}

在这个代码块中,我们使用了generateTrainAndTestDataSet()方法来生成训练集和测试集:

static void generateTrainAndTestDataSet(String ratingsDatPath, String trainDataPath, String testDataPath) throws IOException {
        BufferedWriter writerTrain = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(trainDataPath)));

        BufferedWriter writerTest = new BufferedWriter(new OutputStreamWriter(
                        new FileOutputStream(testDataPath)));

        BufferedReader bufferedReader = new BufferedReader(new FileReader(ratingsDatPath));
        List<String> dummyData = new ArrayList<>();
        String line;

        while((line = bufferedReader.readLine()) != null) {
            String removeDots = line.replaceAll("::", "\t");
            dummyData.add(removeDots);
        }

        bufferedReader.close();

        Random generator = new Random();
        int dataSize = dummyData.size();
        int trainDataSize = (int)(dataSize * (2.0 / 3.0));
        int i = 0;

        while(i < trainDataSize){
            int random = generator.nextInt(dummyData.size()-0) + 0;
            line = dummyData.get(random);
            dummyData.remove(random);
            writerTrain.write(line);
            writerTrain.newLine();
            i++;
        }

        int j = 0;
        while(j < (dataSize - trainDataSize)){
            writerTest.write(dummyData.get(j));
            writerTest.newLine();
            j++;
        }

        writerTrain.flush();
        writerTrain.close();

        writerTest.flush();
        writerTest.close();
    }

前述方法将 2/3 作为训练集,1/3 作为测试集。最后,文件指针被关闭,以释放资源。如果前三个 if 语句成功执行,您应该会看到生成了两个索引文件和两个其他文件(分别用于训练集和测试集):

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/f570978c-1cf1-4616-a08b-e0e0228224a3.png

然后,我们创建一个推荐接口,它将由FMRecommender类使用,该类生成没有任何推荐项限制的推荐:

Map<String, Supplier<Recommender<Long, Long>>> recMap = new HashMap<>();

最后,包装偏好因子分解机以与 RankSys 用户-偏好对配合使用。然后,我们通过设置学习率、正则化和标准差来训练模型,并使用PointWiseGradientDescent迭代训练最多 100 次。FM 然后使用类似 RMSE 的损失,并通过负实例的平衡抽样:

// Use Factorisation machine that uses RMSE-like loss with balanced sampling of negative instances:
String outFileName = "outFolder/Ranking_RMSE.txt";
recMap.put(outFileName, Unchecked.supplier(() -> {
            double negativeProp = 2.0D;

            FMData fmTrain = new OneClassPreferenceFMData(trainData, negativeProp);
            FMData fmTest = new OneClassPreferenceFMData(testData, negativeProp);

            double learnRate = 0.01D; // Learning Rate
            int numIter = 10; // Number of Iterations
            double sdev = 0.1D;
            double regB = 0.01D;

            double[] regW = new double[fmTrain.numFeatures()];
            Arrays.fill(regW, 0.01D);
            double[] regM = new double[fmTrain.numFeatures()];

            Arrays.fill(regM, 0.01D);
            int K = 100;

            // returns enclosed FM
 FM fm = new FM(fmTrain.numFeatures(), K, new Random(), sdev);
            (new PointWiseGradientDescent(learnRate, numIter, PointWiseError.rmse(), 
                                          regB, regW, regM)).learn(fm, fmTrain, fmTest);
             // From general purpose factorization machines to preference FM for user-preference  
            PreferenceFM<Long, Long> prefFm = new PreferenceFM<Long, Long>(userIndex, itemIndex, fm);

            return new FMRecommender<Long, Long>(prefFm);
        }));

在前面的代码块中,FM 模型使用learn()方法进行训练,该方法与上一节中用于预测评分的learn()方法非常相似。然后,为了评估模型,首先,我们设置目标用户和SimpleRecommendationFormat,该格式为制表符分隔的用户-项目评分三元组(即原始数据集中的内容):

Set<Long> targetUsers = testData.getUsersWithPreferences().collect(Collectors.*toSet*());
//Format of the recommendation generated by the FM recommender model as <user, prediction)
RecommendationFormat<Long, Long> format = new SimpleRecommendationFormat<>(*lp*, *lp*);
Function<Long, IntPredicate> filter = FastFilters.*notInTrain*(trainData);
int maxLength = 100;

然后,我们调用RecommenderRunner接口来生成推荐并根据格式打印出来:

// Generate recommendations and print it based on the format.
RecommenderRunner<Long, Long> runner = new FastFilterRecommenderRunner<>(userIndex, itemIndex, targetUsers.stream(), filter, maxLength);

 recMap.forEach(Unchecked.biConsumer((name, recommender) -> {
            System.out.println("Ranking prediction is ongoing...");
            System.out.println("Result will be saved at " + name);
            try(RecommendationFormat.Writer<Long, Long> writer = format.getWriter(name)) {
                runner.run(recommender.get(), writer);
            }
        }));

前面的代码块将在测试集上执行评估,并将推荐写入我们之前指定的文本文件:

>>
 Ranking prediction is ongoing...
 Result will be saved at outFolder/Ranking_RMSE.txt
 INFO: iteration n = 1 t = 3.92s
 INFO: iteration n = 2 t = 3.08s
 INFO: iteration n = 3 t = 2.88s
 INFO: iteration n = 4 t = 2.84s
 INFO: iteration n = 5 t = 2.84s
 INFO: iteration n = 6 t = 2.88s
 INFO: iteration n = 7 t = 2.87s
 INFO: iteration n = 8 t = 2.86s
 INFO: iteration n = 9 t = 2.94s
 ...
 INFO: iteration n = 100 t = 2.87s
 Graph plotting...

预测结果已保存在 outFolder/Ranking_RMSE.txt

现在,让我们来看看输出文件:

944 2396 0.9340957389234708
 944 593 0.9299994477666256
 944 1617 0.9207678675263278
 944 50 0.9062805385053954
 944 1265 0.8740234972054955
 944 589 0.872143533435846
 944 480 0.8659624750023733
 944 2028 0.8649344355656503
 944 1580 0.8620307480644472
 944 2336 0.8576568651679782
 944 1196 0.8570902991702303

这张来自输出文件的快照显示了用户 944 对不同电影的预测排名。现在我们可以看到我们的 FM 模型已经预测了用户的电影排名,接下来检查模型在准确性和执行时间方面的表现是有意义的。

为此,我编写了一个名为PlotUtil_Rank.java的类。该类接受度量类型和迭代次数,并使用plot()方法生成图表:

public static void plot(double[] iterationArray, double[] timeArray, String chart_type, int iter) {
        String series = null;
        String title = null;
        String x_axis = null;
        String y_axis = null;

        if(chart_type =="MSE"){        
            series = "MSE per Iteration (" + iter + " iterations)";
            title = "MSE per Iteration (" + iter + " iterations)";
            x_axis = "Iteration";
            y_axis = "MSE";
        }else {
            series = "Time per Iteration (" + iter + " iterations)";
            title = "Time per Iteration (" + iter + " iterations)";
            x_axis = "Iteration";
            y_axis = "Time";            
        }
            final XYSeriesCollection dataSet = new XYSeriesCollection();
            addSeries(dataSet, iterationArray, timeArray, series);

            final JFreeChart chart = ChartFactory.createXYLineChart(
                    title, // chart title
                    x_axis, // x axis label
                    y_axis, // y axis label
                    dataSet, // data
                    PlotOrientation.VERTICAL,
                    true, // include legend
                    true, // tooltips
                    false // urls
                    );

        final ChartPanel panel = new ChartPanel(chart);
        final JFrame f = new JFrame();
        f.add(panel);
        f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE);
        f.pack();
        f.setVisible(true);
    }

该方法进一步从PointWiseGradientDescent.java类中调用。首先,我们创建两个ArrayList类型的Double来存储执行时间和 MSE:

//PointWiseGradientDescent.java
List<Double> timeList = new ArrayList<Double>();
List<Double> errList = new ArrayList<Double>();

然后,对于每次迭代,; learn() 方法会生成每次迭代的 MSE 误差和时间,并将它们放入列表中:

iter = t;
long time1 = System.*nanoTime*() - time0;
iterationList.add((double)iter);
timeList.add((double)time1 / 1_000_000_000.0);
errList.add(error(fm, test));

最后,调用plot()方法绘制图表,如下所示:

PlotUtil_Rank.*plot*(convertobjectArraytoDouble(iterationList.toArray()),     convertobjectArraytoDouble(errList.toArray()), "MSE", iter); 

PlotUtil_Rank.*plot*(convertobjectArraytoDouble(iterationList.toArray()), convertobjectArraytoDouble(timeList.toArray()), "TIME", iter);

顺便提一下,convertobjectArraytoDouble(),在下面的代码中显示,用于将对象数组转换为双精度数值,以作为绘图的数据点:

public double [] convertobjectArraytoDouble(Object[] objectArray){
 double[] doubleArray = newdouble[objectArray.length];
               //Double[ ]doubleArray=new Double();
 for(int i = 0; i < objectArray.length; i++){
                   Object object = objectArray[i]; 
                   String string = object.toString(); double dub = Double.*valueOf*(string).doubleValue();
                   doubleArray[i] = dub;
                       }
 return doubleArray;
     }

上述调用应该生成两个图表。首先,我们看到每次迭代的 MSE,接下来的图表报告了 100 次迭代的相同数据:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/07ab7d7b-9aca-4220-9d58-84f427c6cee1.png

每次迭代的 MSE(最多到第 100 次迭代)

然后,我们看到每次迭代的时间,接下来的图表报告了第 100 次迭代的相同数据:

https://github.com/OpenDocCN/freelearn-dl-pt6-zh/raw/master/docs/java-dl-proj/img/dfe6c1de-3150-473f-9461-dce817101237.png

每次迭代的时间(最多到第 100 次迭代)

最后,从第二个图表中,我们无法得出重要的结论,除了每次迭代的执行时间波动很大。然而,在第 90 次迭代时,每次迭代所需的时间已经达到饱和。

另一方面,MSE 在第 20 次迭代后大幅下降,从 0.16 降到 0.13,但在第 25 次迭代后趋于饱和。这意味着仅增加迭代次数并不能进一步减少 MSE。因此,我建议你在改变迭代次数的同时,也要尝试调整其他超参数。

常见问题解答(FAQs)

现在我们已经看到如何开发一个电影推荐系统,预测用户对电影的评分和排名,还有一些问题也需要我们关注。同时,本章没有涵盖/讨论该库,因此我建议你仔细阅读文档。

然而,我们仍然会在本节中看到一些你可能已经想过的常见问题。你可以在附录中找到这些问题的答案。

  1. 如何保存一个训练好的 FM 模型?

  2. 如何从磁盘恢复一个已保存的 FM 模型?

  3. 我可以使用 FM 算法来解决分类任务吗?

  4. 给我几个 FM 算法应用的示例用例。

  5. 我可以使用 FM 算法进行 Top-N 推荐吗?

总结

在本章中,我们学习了如何使用 FM 开发一个电影推荐系统,FM 是一组算法,通过以监督的方式引入缺失在矩阵分解算法中的二阶特征交互,从而增强线性模型的性能。

然而,在深入讨论使用基于 RankSys 库的 FM 实现项目之前,我们已经看过了使用矩阵分解和协同过滤的推荐系统的理论背景。由于页面限制,我没有详细讨论该库。建议读者查阅 GitHub 上的 API 文档:github.com/RankSys/RankSys

这个项目不仅涵盖了由个人用户进行的电影评分预测,还讨论了排名预测。因此,我们还使用了 FM(因子分解机)来预测电影的排名。

这或多或少是我们开发一个端到端 Java 项目的旅程的结束。然而,我们还没有结束!在下一章中,我们将讨论一些深度学习的最新趋势。然后,我们将看到一些可以使用 DL4J 库实现的新兴用例,或者至少我们会看到一些指引。

问题的答案

问题 1 的答案:为此,您可以通过提供输入模型文件名来调用saveModel()方法:

String FILENAME = Constants.FILENAME;
// Save the trained FM model 
fmlsgd.saveModel(FILENAME);

saveModel()方法如下:

public void saveModel(String FILENAME) throws Exception
    {
        FILENAME = Constants.FILENAME;
        FileOutputStream fos = null;
        DataOutputStream dos = null;        
        try {      
            fos = new FileOutputStream(FILENAME);
            dos = new DataOutputStream(fos);
            dos.writeBoolean(fm.k0);
            dos.writeBoolean(fm.k1);
            dos.writeDouble(fm.w0);
            dos.writeInt(fm.num_factor);
            dos.writeInt(fm.num_attribute);
            dos.writeInt(task.ordinal());
            dos.writeDouble(max_target);
            dos.writeDouble(min_target);

            for(int i=0;i<fm.num_attribute;i++)
            {
                dos.writeDouble(fm.w[i]);
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                dos.writeDouble(fm.m_sum[i]);
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                dos.writeDouble(fm.m_sum_sqr[i]);
            }

            for(int i_1 = 0; i_1 < fm.num_factor; i_1++) {
                for(int i_2 = 0; i_2 < fm.num_attribute; i_2++) {                    
                    dos.writeDouble(fm.v.get(i_1,i_2));
                }
            }

            dos.flush();
        }
        catch(Exception e) {
            throw new JlibfmRuntimeException(e);
        } finally {          
             if(dos!=null)
                dos.close();
             if(fos!=null)
                fos.close();
        }
    }

然后,该方法将把训练模型的所有元数据(包括维度、排名、权重和属性信息)保存到磁盘。

问题 2 的答案:为此,您可以通过提供输入模型文件名来调用restoreModel()方法:

public void restoreModel(String FILENAME) throws Exception
    {
        FILENAME = Constants.FILENAME;
        InputStream is = null;
        DataInputStream dis = null;        
        try {      
            is = new FileInputStream(FILENAME);          
            dis = new DataInputStream(is);

            fm.k0 = dis.readBoolean();
            fm.k1 = dis.readBoolean();
            fm.w0 = dis.readDouble();
            fm.num_factor = dis.readInt();
            fm.num_attribute = dis.readInt();

            if(dis.readInt() == 0)
            {
               task = TaskType.TASK_REGRESSION;
            }
            else
            {
               task = TaskType.TASK_CLASSIFICATION;
            }

            max_target = dis.readDouble();
            min_target = dis.readDouble();

            fm.w = new double[fm.num_attribute];

            for(int i=0;i<fm.num_attribute;i++)
            {
                fm.w[i] = dis.readDouble();
            }

            fm.m_sum = new double[fm.num_factor];
            fm.m_sum_sqr = new double[fm.num_factor];

            for(int i=0;i<fm.num_factor;i++)
            {
               fm.m_sum[i] = dis.readDouble();
            }

            for(int i=0;i<fm.num_factor;i++)
            {
                fm.m_sum_sqr[i] = dis.readDouble();
            }

            fm.v = new DataPointMatrix(fm.num_factor, fm.num_attribute);

            for(int i_1 = 0; i_1 < fm.num_factor; i_1++) {
                for(int i_2 = 0; i_2 < fm.num_attribute; i_2++) {        
                    fm.v.set(i_1,i_2, dis.readDouble());
                }
            }

        }
        catch(Exception e) {
            throw new JlibfmRuntimeException(e);
        } finally {          
             if(dis!=null)
                dis.close();
             if(is!=null)
                is.close();
        }
    }

该方法的调用将恢复保存的模型,包括从磁盘加载的训练模型的所有元数据(例如,维度、排名、权重和属性信息)。

问题 3 的答案:是的,当然可以。这种算法对于非常稀疏的数据集也非常有效。您只需要确保预测标签为整数,并且任务类型是分类,即task == TaskType.TASK_CLASSIFICATION

问题 4 的答案:有几个使用 FM 方法的应用场景。例如:

问题 5 的回答:是的,你可以从隐式反馈中提取(如评论、事件、交易等),因为将评分预测结果转换为 Top-N 列表是一个简单的工作。然而,我认为目前没有开源实现可用,但你当然可以通过大幅修改 LibFM 来尝试使用成对排序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值