Powered by AppSignal & Oban Pro
Would you like to see your link here? Contact us

Talk

notebooks/talk.livemd

Talk

Mix.install([
  {:nx, github: "elixir-nx/nx", sparse: "nx", override: true},
  {:scholar, github: "elixir-nx/scholar", branch: "main"},
  {:optimus, "~> 0.1"},
  {:explorer, "~> 0.5.6"},
  {:exla, "~> 0.5.2"},
  {:req, "~> 0.3.6"},
  {:kino_vega_lite, "~> 0.1.8"},
  {:kino, "~> 0.9.3"},
  {:kino_explorer, "~> 0.1.6"},
  {:scidata, "~> 0.1"}
])

Tensors

The tensor arg can be one of many things

  • A number (aka scalar)
  • A boolean (synatic sugar on a special 0/1 tensor)
  • A list of numbers, booleans, or list of numbers and booleans
  • A tensor (why? well the opts we haven’t talke about yet would then be used to tweak an existing tensor)
scalar = Nx.tensor(48)
true_scalar = Nx.tensor(true)
false_scalar = Nx.tensor(false)
vector = Nx.tensor([4, 8])
matrix = Nx.tensor([[1, 1, 2], [2, 2, 4]])

{scalar, true_scalar, false_scalar, vector, matrix}

Pizzeria

Based on the last week of data we see the following

| Date | # Resies | # Pizzas | — | — | — | | Monday | 12 | 36 | Tuesday | 20 | 72 | Wednesday | 18 | 68 | Thursday | 30 | 82 | Friday | 0 | 98 | Saturday | 0 | 168 | Sunday | 24 | 75 |

Now, the resaurant doesn’t take reservations on Friday and Saturday. So how should we proceed? We can drop the data and then not predict on Friday’s and Saturdays. Or, we could estimate the “resrvations” based on how many “flips” we normally get. For today, we will just drop that data. Here’s our model

Load The Data

x = Nx.stack([12, 20, 18, 30, 24]) |> Nx.stack(axis: -1)
y = Nx.tensor([36, 72, 68, 82, 75])

Visualize The Data

require Explorer.DataFrame, as: DF
alias VegaLite, as: Vl

df = DF.new(x: x, y: y)

vl =
  Vl.new(title: ["Pizzeria"], width: 630, height: 630)
  |> Vl.data_from_values(df)
  |> Vl.mark(:circle, size: 500)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative)

Fit The Data

alias Scholar.Linear.LinearRegression, as: LR
model = LR.fit(x, y)

Extract The Linear Equation

The equation is y = mx + b where m is the coefficients (just one) and the b is intercept (a scalar).

[m] = Nx.to_flat_list(model.coefficients)
[b] = Nx.to_flat_list(model.intercept)

"y = #{m}x + #{b}"

Visualize the The Equation

Lines are simple to graph, you need a start x=0 and the end x=30 (why 30, as that’s the biggest x value in our data above).

line = %{
  x: [0, 30],
  y: [m * 0 + b, m * 30 + b]
}

Vl.new(title: ["Pizzeria Line"], width: 630, height: 630)
|> Vl.layers([
  Vl.new()
  |> Vl.data_from_values(df)
  |> Vl.mark(:circle, size: 500, color: :green)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(line)
  |> Vl.mark(:line, size: 4, color: :red)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative)
])

Estimate Pizza Doughs

example_xs = [15]

prediction_ys =
  for e <- example_xs do
    LR.predict(model, e)
    |> Nx.to_flat_list()
    |> List.first()
  end

inferences = %{x: example_xs, y: prediction_ys}

Vl.new(title: ["Pizzeria Line"], width: 630, height: 630)
|> Vl.layers([
  Vl.new()
  |> Vl.data_from_values(df)
  |> Vl.mark(:circle, size: 100, color: :green)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(line)
  |> Vl.mark(:line, size: 4, color: :red)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(inferences)
  |> Vl.mark(:circle, size: 300, color: :purple)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative)
])

Deploy Model

Our model might not be perfect, but it’s trained and built, so how do we get our system to then use that model in our code? We don’t want to re-train every time we want to predict something.

model
|> Nx.serialize()
|> then(&amp;File.write!("./pizzeria.nx", &amp;1))

What can be serialized can then be deserialized to predict future reservation needs. Using the file based approach, we can actually compile in the model and build time and its just another data structure for elixir to use.

mdl = File.read!("./pizzeria.nx") |> Nx.deserialize()

As the model is just data representing the algorihtm, we can use to make prediction.

LR.predict(mdl, 15)
|> Nx.to_flat_list()
|> List.first()

Evaluate Model

Our data is quite sparse on many fronts… only a weeks worth of reservation numbers AND only 1 dimension of data, the # of reservations. And, it might not actually be a good approximation to assume the relationship is linear. We call this “underfitting” where we don’t have enough of the right stuff to make a good prediction.

We started with a weeks worth, but now we have a years worth of data and looks like our linear regression does not seem to be giving our Pizzeria friend great results. Looks like the data seems to follow more of a curve than a line, so with a one-line change to use PolynomialRegression we almost get a 50% reduction in error.

defmodule Pizzeria.Migrations.Seeds do
  @dates [
    {1, 31},
    {2, 28},
    {3, 31},
    {4, 30},
    {5, 31},
    {6, 30},
    {7, 31},
    {8, 31},
    {9, 30},
    {10, 31},
    {11, 30},
    {12, 31}
  ]

  def run() do
    for {_, num} <- @dates,
        _ <- 1..num do
      num_resi = :rand.uniform(60) - 1

      if num_resi > 0 do
        weight = 10 + (:rand.uniform() * 0.1 - 0.5)
        bias = 15 + (:rand.uniform() * 2.0 - 1.0)
        num_pizzas = round(weight * :math.log2(num_resi) + bias)
        [num_resi, num_pizzas]
      else
        nil
      end
    end
    |> Enum.reject(&amp;is_nil/1)
  end
end

data = Pizzeria.Migrations.Seeds.run() |> Nx.tensor()

Let’s split our data into test and train.

{n, 2} = Nx.shape(data)

train_size = round(n * 0.8)
test_size = n - train_size

# Probably a better way
traindata = data |> Nx.slice_along_axis(0, test_size)
testdata = data |> Nx.slice_along_axis(test_size, train_size)

x = traindata[[.., 0..0]]
y = traindata[[.., 1]]

testx = testdata[[.., 0..0]]
testy = testdata[[.., 1]]

model = LR.fit(x, y)

Visualize Updated Data

Based on way more data, let’s visualize our curve.

df = DF.new(x: x, y: y)
testdf = DF.new(x: testx, y: testy)

[m] = Nx.to_flat_list(model.coefficients)
[b] = Nx.to_flat_list(model.intercept)

line = %{
  x: [0, 60],
  y: [m * 0 + b, m * 60 + b]
}

Vl.new(title: ["Pizzeria Line"], width: 630, height: 630)
|> Vl.layers([
  Vl.new()
  |> Vl.data_from_values(df)
  |> Vl.mark(:circle, size: 100, color: :green)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(testdf)
  |> Vl.mark(:circle, size: 100, color: :blue)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative),
  Vl.new()
  |> Vl.data_from_values(line)
  |> Vl.mark(:line, size: 4, color: :red)
  |> Vl.encode_field(:x, "x", type: :quantitative)
  |> Vl.encode_field(:y, "y", type: :quantitative)
])

We can then evaluate our model by measuring the error of each prediction in our test dataset.

model
|> LR.predict(testx)
|> Nx.subtract(testy)
|> Nx.abs()
|> Nx.mean()

From our visual inspection, maybe a curve is better. Let’s retrain and re-evaluate our model.

# But we need bleeding edge Scholar / Nx and it wasn't working in livebook....
# {:nx, github: "elixir-nx/nx", sparse: "nx", override: true},
# {:scholar, github: "elixir-nx/scholar", branch: "main"},

alias Scholar.Linear.PolynomialRegression, as: PR
model = PR.fit(x, y)

Let’s visualize this line

df = DF.new(x: x, y: y)

traindf = DF.new(x: testx, y: testy)

polyx = Enum.to_list(1..60)

polyy =
  polyx
  |> Enum.map(&amp;Nx.tensor([[&amp;1]]))
  |> Enum.map(&amp;(PR.predict(model, &amp;1) |> Nx.to_flat_list() |> List.first()))

Vl.new(
  title: [text: "Pizza Dough Approximation"],
  width: 630,
  height: 630
)
|> Vl.layers([
  Vl.new()
  |> Vl.data_from_values(df)
  |> Vl.mark(:circle, color: :blue)
  |> Vl.encode_field(:x, "x", type: :quantitative, axis: [grid: false])
  |> Vl.encode_field(:y, "y", type: :quantitative, axis: [grid: false]),
  Vl.new()
  |> Vl.data_from_values(traindf)
  |> Vl.mark(:square, color: :red)
  |> Vl.encode_field(:x, "x", type: :quantitative, axis: [grid: false])
  |> Vl.encode_field(:y, "y", type: :quantitative, axis: [grid: false]),
  Vl.new()
  |> Vl.data_from_values(line)
  |> Vl.mark(:line, color: :lightgrey, weight: 1)
  |> Vl.encode_field(:x, "x", type: :quantitative, axis: [grid: false])
  |> Vl.encode_field(:y, "y", type: :quantitative, axis: [grid: false]),
  Vl.new()
  |> Vl.data_from_values(%{x: polyx, y: polyy})
  |> Vl.mark(:line, color: :green, weight: 2)
  |> Vl.encode_field(:x, "x", type: :quantitative, axis: [grid: false])
  |> Vl.encode_field(:y, "y", type: :quantitative, axis: [grid: false])
])

And then we can re-evaluate the model

model
|> PR.predict(testx)
|> Nx.subtract(testy)
|> Nx.abs()
|> Nx.mean()