乐趣区

关于elixir:译-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…

退出移动版