[译] Nx 入门 -- Sean Moriarity

Nx 是一个 BEAM 上的,用于操作张量(tensor)和数值计算的新库。Nx 期望为elixir、erlang以及其它 BEAM 语言打开一扇大门,通往一个崭新的领域 -- 用户能够使用 JIT 和高度特殊化的 tensor 操作来加速他们的代码。本文中,你会学到基础的操作 Nx 的方法,以及如何将其用于机器学习应用中。

适应 Tensor

Nx 的 Tensor 类似于 PyTorch 或 TensorFlow 的 tensor,NumPy 的多维数组。用过它们,那就好办。不过它与数学定义不完全一致。Nx 从 Python 生态里借鉴了许多,所以适应起来应该是很容易。Elixir 程序员可以把 tensor 想象为嵌套列表,附带了一些元数据。

iex> Nx.tensor([[1, 2, 3], [4, 5, 6]])
#Nx.Tensor<
  s64[2][3]
  [
    [1, 2, 3],
    [4, 5, 6]
  ]
>

Nx.tensor/2 是用来创建 tensor 的,它可以接受嵌套列表和标量:

iex> Nx.tensor(1.0)
#Nx.Tensor<
  f32
  1.0
>

元数据在 tensor 被检视时可以看到,比如例子里的 s64[2][3]f32。Tensor 有形状和类型。每个维度的长度所组成的元祖构成了形状。在上面的例子里第一个 tensor 的形状是 {2, 3},表示为 [2][3]

iex> Nx.shape(Nx.tensor([[1, 2, 3], [4, 5, 6]]))
{2, 3}

把 tensor 想象为嵌套列表的话,就是两个列表,每个包含3个元素。嵌套更多:

iex> Nx.shape(Nx.tensor([[[[1, 2, 3], [4, 5, 6]]]]))
{1, 1, 2, 3}

1个列表,其包含1个列表,其包含2个列表,其包含3个元素。

这种思维在处理标量时可能会有点困惑。标量的形状是空元组:

iex> Nx.shape(Nx.tensor(1.0))
{}

因为标量是 0 维的 tensor。它们没有任何维度,所以是“空”形。

Tensor 的类型就是其中数值的类型。Nx里类型表示为一个二元元组,包含类与长度或比特宽度:

iex> Nx.type(Nx.tensor([[1, 2, 3], [4, 5, 6]]))
{:s, 64}
iex> Nx.type(Nx.tensor(1.0))
{:f, 32}

类型很重要,它告诉 Nx 在内部应该如何保存 tensor。Nx 的 tensor 在底层表示为binary:

iex> Nx.to_binary(Nx.tensor(1))
<<1, 0, 0, 0, 0, 0, 0, 0>>
iex> Nx.to_binary(Nx.tensor(1.0))
<<0, 0, 128, 63>>

关于大端小端,Nx 使用的是硬件本地的端序。如果你需要 Nx 使用指定的大小端,你可以提一个 issue 来描述使用场景。

Nx 会自动判断输入的类型,你也可以指定某种类型:

iex> Nx.to_binary(Nx.tensor(1, type: {:f, 32}))
<<0, 0, 128, 63>>
iex> Nx.to_binary(Nx.tensor(1.0))
<<0, 0, 128, 63>>

因为 Nx tensor 内部表示是 binary,所以你不应该使用 Nx.tensor/2,它在创造特别大的 tensor 时会非常昂贵。Nx 提供了 Nx.from_binary/2 这个方法,不需要遍历嵌套列表:

iex> Nx.from_binary(<<0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 64, 64>>, {:f, 32})
#Nx.Tensor<
  f32[3]
  [1.0, 2.0, 3.0]
>

Nx.from_binary/2 输入一个 binary 和类型,返回一个一维的 tensor。如果你想改变形状,可以用 Nx.reshape/2

iex> Nx.reshape(Nx.from_binary(<<0, 0, 128, 63, 0, 0, 0, 64, 0, 0, 64, 64>>, {:f, 32}), {3, 1})
#Nx.Tensor<
  f32[3][1]
  [
    [1.0],
    [2.0],
    [3.0]
  ]
>

reshape 只是改变了形状属性,所以是想当便宜的操作。当你有binary格式的数据,使用 from_binary 在 reshape 是最高效的做法。

Tensor 操作

如果你是 Elixir 程序员,一定很熟悉 Enum 模块。因此,你可能会想要使用 mapreduce 方法。Nx 提供了这些方法,但你应当不去使用它们。

Nx 里的所有操作都是 tensor 相关的,即它们可用于任意形状和类型的 tensor。例如,在 Elixir 里你可能习惯这样做:

iex> Enum.map([1, 2, 3], fn x -> :math.cos(x) end)
[0.5403023058681398, -0.4161468365471424, -0.9899924966004454]

但在 Nx 里你可以这样:

iex> Nx.cos(Nx.tensor([1, 2, 3]))
#Nx.Tensor<
  f32[3] 
  [0.5403022766113281, -0.416146844625473, -0.9899924993515015]
>

Nx 里所有的一元操作都是这样 -- 将一个函数应用于 tensor 里的所有元素:

iex> Nx.exp(Nx.tensor([[[1], [2], [3]]]))
#Nx.Tensor<
  f32[1][3][1]
  [
    [
      [2.7182817459106445],
      [7.389056205749512],
      [20.08553695678711]
    ]
  ]
>
iex> Nx.sin(Nx.tensor([[1, 2, 3]]))
#Nx.Tensor<
  f32[1][3]
  [
    [0.8414709568023682, 0.9092974066734314, 0.14112000167369843]
  ]
>
iex> Nx.acosh(Nx.tensor([1, 2, 3]))
#Nx.Tensor<
  f32[3] 
  [0.0, 1.316957950592041, 1.7627471685409546]
>

几乎没必要使用 Nx.map,因为对元素的一元操作总是可以达到相同的效果。Nx.map 总是会低效一些,而且你没法使用类似 grad 的变换。此外,一些 Nx 后端和编译器不支持 Nx.map,所以可移植性也是问题。Nx.reduce 也是一样。使用 Nx 提供的聚合方法,类似 Nx.sum, Nx.mean, Nx.product 是比 Nx.reduce 更好的选择:

iex> Nx.sum(Nx.tensor([1, 2, 3]))
#Nx.Tensor<
  s64
  6
>
iex> Nx.product(Nx.tensor([1, 2, 3]))
#Nx.Tensor<
  s64
  6
>
iex> Nx.mean(Nx.tensor([1, 2, 3]))
#Nx.tensor<
  f32
  2.0
>

Nx 聚合方法还支持在单个轴上的聚合。例如,如果你有一揽子样本,你可能只想计算单个样本的均值:

iex> Nx.mean(Nx.tensor([[1, 2, 3], [4, 5, 6]]), axes: [1])
#Nx.Tensor<
  f32[2] 
  [2.0, 5.0]
>

甚至给定多个轴:

iex> Nx.mean(Nx.tensor([[[1, 2, 3], [4, 5, 6]]]), axes: [0, 1])
#Nx.Tensor<
  f32[3] 
  [2.5, 3.5, 4.5]
>

Nx 还提供了二元操作。例如加减乘除:

iex> Nx.add(Nx.tensor([1, 2, 3]), Nx.tensor([4, 5, 6]))
#Nx.Tensor<
  s64[3]
  [5, 7, 9]
>
iex> Nx.subtract(Nx.tensor([[1, 2, 3]]), Nx.tensor([[4, 5, 6]]))
#Nx.Tensor<
  s63[1][3]
  [-3, -3, -3]
>
iex> Nx.multiply(Nx.tensor([[1], [2], [3]]), Nx.tensor([[4], [5], [6]]))
#Nx.Tensor<
  s64[3][1]
  [
    [4],
    [10],
    [18]
  ]
>
iex> Nx.divide(Nx.tensor([1, 2, 3]), Nx.tensor([4, 5, 6]))
#Nx.Tensor<
  f32[3] 
  [0.25, 0.4000000059604645, 0.5]
>

二元操作有一个限定条件,那就是tensor 的形状必须能广播到一致。在输入的 tensor 形状不同时会触发广播:

iex> Nx.add(Nx.tensor(1), Nx.tensor([1, 2, 3]))
#Nx.Tensor<
  s64[3]
  [2, 3, 4]
>

这里,标量被广播成了更大的 tensor。广播可以让我们实现更节约内存的操作。比如,你想把一个 50x50x50 的tensor 乘以 2,你可以直接借助广播,而不需要创造另一个全是 2 的 50x50x50 的 tensor。

广播的两个 tensor 的每个维度必须是匹配的。当符合下列条件中的一个时,维度就是匹配的:

  1. 他们相等。
  2. 其中一个等于 1.

当你试图广播不匹配的 tensor 时,会遇到如下报错:

iex> Nx.add(Nx.tensor([[1, 2, 3], [4, 5, 6]]), Nx.tensor([[1, 2], [3, 4]]))
** (ArgumentError) cannot broadcast tensor of dimensions {2, 3} to {2, 2}
    (nx 0.1.0-dev) lib/nx/shape.ex:241: Nx.Shape.binary_broadcast/4
    (nx 0.1.0-dev) lib/nx.ex:2430: Nx.element_wise_bin_op/4

如果需要的话,你可以用 expanding, padding, slicing 输入的 tensor 来解决广播问题;但要小心。

基础线性回归

目前为止,我们只是在 iex 里面学习简单的例子。我们所有的例子都可以被 Enum 和列表来实现。本小节,我们要展现 Nx 真正的力量,使用梯度下降来解决基础线性回归问题。

创建一个新的 Mix 项目,包含 Nx 和它的后端。在这里,我会使用 EXLA, 你也可以使用 Torchx。他们有一些区别,但都可以运行下面的例子。

def deps do
  [
    {:exla, "~> 0.2"},
    {:nx, "~> 0.2"}
  ]
end

然后运行:

$ mix deps.get && mix deps.compile

第一次运行可能需要一段时间的下载和编译,你可以在 EXLA 的 README 里找到一些提示。

当 Nx 和 EXLA 都编译好后,创建一个新文件 regression.exs。在其中创建一个模块:

defmodule LinReg do
  import Nx.Defn
end

Nx.Defn 模块中包含了 defn 的定义。它是一个可用于定义数值计算的宏。数值计算和 Elixir 函数的使用方法相同,但仅支持一个有限的语言子集,为了支持 JIT。defn 还替换了很多Elixir 的核心方法,例如:

defn add_two(a, b) do
  a + b
end

+ 自动转换成了 Nx.add/2defn 还支持特殊变换:gradgrad 宏会返回一个函数的梯度。梯度反映了一个函数的变化率。细节这里就不提了,现在,只需要掌握如何使用 grad

如上所述,我们将使用梯度下降来解决基本线性回归问题。线性回归是对输入值和输出值之间的关系进行建模。输入值又称为解释值,因为它们具有解释输出值的因果关系。举个实际的例子,你想通过日期、时间、是否有弹窗来预测网站的访问量。你可以收集几个月以来的数据,然后建立一个基础回归模型来预测日均访问量。

在我们的例子中,我们将会建立一个有一个输入值的模型。首先,在LinReg 模块之外定义我们的训练集:

target_m = :rand.normal(0.0, 10.0)
target_b = :rand.normal(0.0, 5.0)
target_fn = fn x -> target_m * x + target_b end
data =
  Stream.repeatedly(fn -> for _ <- 1..32, do: :rand.uniform() * 10 end)
  |> Stream.map(fn x -> Enum.zip(x, Enum.map(x, target_fn)) end)
IO.puts("Target m: #{target_m}\tTarget b: #{target_b}\n")

首先,我们定义了 target_m,target_b,target_fn。我们的线性方程是 y = m*x +b,所以我们使用 Stream 来重复生成了一揽子输入输出对。我们的目标是使用梯度下降来学习 target_mtarget_b

接下来我们要定义的是模型。模型是一个参数化的函数,将输入转化为输出。我们知道我们的函数格式是 y = m*x + b,所以可以这样定义:

defmodule LinReg do
  import Nx.Defn
  defn predict({m, b}, x) do
    m * x + b
  end
end

接着,我们需要定义损失 (loss) 函数。Loss 函数通常用来测量预测值和真实值的误差。它能告诉你模型的优劣。我们的目标是最小化loss函数。

对于线性回归问题,最常用的损失函数是均方误差 mean—squared error (MSE):

defn loss(params, x, y) do
  y_pred = predict(params, x)
  Nx.mean(Nx.power(y - y_pred, 2))
end

MSE 测量目标值和预测的平均方差。越接近,则 MSE 越趋近于零。我们还需要一个方法来更新模型,使得 loss 减小。我们可以使用梯度下降。它计算 loss 函数的梯度。梯度能告诉我们如何更新模型参数。

一开始很难讲清楚梯度下降在做什么。想象你正在寻找一个湖的最深处。你有一个测量仪在船上,但没有其它信息。你可以搜查整个湖,但这会耗费无限的时间。你可以每次在一个小范围里找到最深的点。比如,你测量出往左走深度从5变成7,往右走深度从5变成3,那么你应该往左走。这就是梯度下降所做的,给你一些如何改变参数空间的信息。

你可以通过计算损失函数的梯度,来更新参数:

defn update({m, b} = params, inp, tar) do
  {grad_m, grad_b} = grad(params, &loss(&1, inp, tar))
  {
    m - grad_m * 0.01,
    b - grad_b * 0.01
  }
end

grad 输入你想要获取梯度的参数,以及一个参数化的函数,在这里就是损失函数。grad_mgrad_b 分别是 mb 的梯度。通过将 grad_m 缩小到 0.01 倍,再用 m 减去这个值,来更新 m。这里的 0.01 也叫学习指数。我们想每次移动一小步。

update返回更新后的参数。在这里我们需要mb的初始值。在寻找深度的例子里,想象你有一个朋友知道最深处的大概位置。他告诉你从哪里开始,这样我们能够更快地找到目标:

defn init_random_params do
  m = Nx.random_normal({}, 0.0, 0.1)
  b = Nx.random_normal({}, 0.0, 0.1)
  {m, b}
end

init_random_params 随机生成均值 0.0 方差 0.1 的参数 m 和 b。现在你需要写一个训练循环。训练循环输入几捆样本,并且应用 update,直到某些条件达到时才停止。在这里,我们将10次训练 200 捆样本:

def train(epochs, data) do
  init_params = init_random_params()
  for _ <- 1..epochs, reduce: init_params do
    acc ->
      data
      |> Enum.take(200)
      |> Enum.reduce(
        acc,
        fn batch, cur_params ->
          {inp, tar} = Enum.unzip(batch)
          x = Nx.tensor(inp)
          y = Nx.tensor(tar)
          update(cur_params, x, y)
        end
      )
  end
end

在训练循环里,我们从 stream 中提取200捆数据,在每捆数据后更新模型参数。我们重复epochs次,在每次更新后返回参数。现在,我们只需要调用 LinReg.train/2 来返回学习后的 m 和 b:

{m, b} = LinReg.train(100, data)
IO.puts("Learned m: #{Nx.to_scalar(m)}\tLearned b: #{Nx.to_scalar(b)}")

总之,regression.exs 现在应该是:

defmodule LinReg do
  import Nx.Defn
  defn predict({m, b}, x) do
    m * x + b
  end
  defn loss(params, x, y) do
    y_pred = predict(params, x)
    Nx.mean(Nx.power(y - y_pred, 2))
  end
  defn update({m, b} = params, inp, tar) do
    {grad_m, grad_b} = grad(params, &loss(&1, inp, tar))
    {
      m - grad_m * 0.01,
      b - grad_b * 0.01
    }
  end
  defn init_random_params do
    m = Nx.random_normal({}, 0.0, 0.1)
    b = Nx.random_normal({}, 0.0, 0.1)
    {m, b}
  end
  def train(epochs, data) do
    init_params = init_random_params()
    for _ <- 1..epochs, reduce: init_params do
      acc ->
        data
        |> Enum.take(200)
        |> Enum.reduce(
          acc,
          fn batch, cur_params ->
            {inp, tar} = Enum.unzip(batch)
            x = Nx.tensor(inp)
            y = Nx.tensor(tar)
            update(cur_params, x, y)
          end
        )
    end
  end
end
target_m = :rand.normal(0.0, 10.0)
target_b = :rand.normal(0.0, 5.0)
target_fn = fn x -> target_m * x + target_b end
data =
  Stream.repeatedly(fn -> for _ <- 1..32, do: :rand.uniform() * 10 end)
  |> Stream.map(fn x -> Enum.zip(x, Enum.map(x, target_fn)) end)
IO.puts("Target m: #{target_m}\tTarget b: #{target_b}\n")
{m, b} = LinReg.train(100, data)
IO.puts("Learned m: #{Nx.to_scalar(m)}\tLearned b: #{Nx.to_scalar(b)}")

现在你可以这样运行:

$ mix run regression.exs
Target m: -0.057762353079829236 Target b: 0.681480460783122
Learned m: -0.05776193365454674 Learned b: 0.6814777255058289

看我们的预测结果是多么地接近!我们已经成功地使用梯度下降来实现线性回归;然而我们还可以更进一步。

你应该注意到了,100个epochs的训练花费了一些时间。因为我们没有利用EXLA提供的JIT编译。因为这是个简单的例子,然而,当你的模型变得复杂,你就需要JIT的加速。首先,我们来看一下EXLA和纯elixir在时间上的区别:

{time, {m, b}} = :timer.tc(LinReg, :train, [100, data])
IO.puts("Learned m: #{Nx.to_scalar(m)}\tLearned b: #{Nx.to_scalar(b)}\n")
IO.puts("Training time: #{time / 1_000_000}s")

在没有任何加速的情况下:

$ mix run regression.exs
Target m: -1.4185910271067492 Target b: -2.9781437461823965
Learned m: -1.4185925722122192  Learned b: -2.978132724761963
Training time: 4.460695s

我们成功完成了学习。这一次,花了 4.5 秒。现在,为了利用EXLA的JIT编译,将下面这个模块属性添加到你的模块中:

defmodule LinReg do
  import Nx.Defn
  @default_defn_compiler EXLA
end

它会告诉Nx使用EXLA编译器来编译所有数值计算。现在,重新运行一遍:

Target m: -3.1572039775886167 Target b: -1.9610560589959405
Learned m: -3.1572046279907227  Learned b: -1.961051106452942
Training time: 2.564152s

运行的结果相同,但时间从4.5s缩短到了2.6s,几乎60%的提速。必须承认,这只是一个很简单的例子,而你在复杂的实现中看到的速度提升远不止这些。比如,你可以试着实现MNIST,一个epoch使用纯elixir将花费几个小时,而EXLA会在0.5s~4s左右完成,取决于你的机器使用的加速器。

总结

本文覆盖了Nx的核心功能。你学到了:

  1. 如何使用 Nx.tensor 和 Nx.from_binary 来创建一个 tensor。2. 如何使用一元,二元和聚合操作来处理 tensor 3. 如何使用 defn 和 Nx 的 grad 来实现梯度下降。4. 如何使用 EXLA 编译器来加速数值计算。

尽管本文覆盖了开始使用 Nx 所需的基础知识,但还是有很多需要学习的。我希望本文可以驱使你继续学习关于 Nx 的项目,并且找到独特的使用场景。Nx仍在早期,有很多激动惹您的东西在前方。

原文: https://dockyard.com/blog/202...

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