DEV Community

Tiemen
Tiemen

Posted on

Linear Regression with Elixir, Phoenix and LiveView. Part II

In the previous post we walked through setting up a fairly simple linear regression algorithm that uses the slope-intercept form and gradient descent to fit the best line possible over a list of given datapoints. In this part we will make this interactive using Phoenix LiveView and a SVG element in the browser.

Kicking things off

In the previous post we've scaffolded a Phoenix LiveView application using mix phx.new --live --no-ecto but up until now we only focused on the non-phoenix parts. Let's begin by starting our development server and see what we're setup with out-of-the-box:

Either iex -S mix phx.server or mix phoenix.server will do. I like the former better because it drops us immediately into an IEx session we can use to inspect, validate or just doodle around.

Visiting http://localhost:4000 will greet us with a default Phoenix LiveView page:

The default Phoenix LiveView page

Now open your favorite editor and look under lib/linreg_web/live. This is where the LiveView modules and their templates live. Go ahead and add two files to this folder: regression_live.ex and regression_live.html.leex.

Setting up the LiveView module and its template

Now that we have a new module for our logic, go ahead and open the regression_live.ex file to start coding our module.

defmodule LinregWeb.RegressionLive do
  use LinregWeb, :live_view

  @impl true
  def mount(_params, _sesion, socket) do
    {:ok, socket}
  end
end

This is the most barebones version of a LiveView module we can write. It defines a new module called RegressionLive which uses the LiveView parts of our application. Its behaviour specifies only one required function mount/3 to kick things off. It knows, by convention, to render the regression_live.html.leex template.

Now, to actually show something in the browser, we'll need to write some HTML. Open the template file regression_live.html.leex and add some basic HTML:

<h1>Hello, LiveView!</h1>

And lastly, in order to be able to view it, we need to hook the LiveView module up to our router. Locate the router.ex file living in lib/linreg_web/ and find the line where it says:

live "/", PageLive, :index

This line tells Phoenix to mount the LiveView module on the root route ("/"), hook it up to the PageLive module and mark it as the :index action.In other words, if there's a request made to our root route, we'll render the PageLive live module.

We want to change that to our own module and see our friendly greeting:

live "/", RegressionLive, :index

Now refresh our browser and be greeted with our own module:

Our own Linreg.RegressionLive module in effect!

Collecting datapoints

Now that we have a basic LiveView setup, we can start working on collecting datapoints to let our model learn against. Let's begin by rendering a SVG plane on screen.

Open our live template (regression_live.html.leex) and write the following code:

<svg phx-click="add_point" width="800" height="800"></svg>

This sets up an empty SVG element with a with and height of 800px. Another important thing that we've added here is the phx-click attribute. This is the LiveView magic that will make it interactive! This ties an event listener to this element that will react to click events on the SVG element and will send off an event called add_point.

Let's setup the eventhandler itself. Open up the regression_live.ex module again and write the following function in there:

@impl true
def handle_event("add_point", params, socket) do
  IO.inspect(params, label: "params")
  {:noreply, socket}
end

The function definition above has three parameters. The first one is the event name, followed by the parameters of that event and lastly the socket which holds our state and causes the template to re-render if anything changes.

All this function does is print out the parameters when the "add_point" event comes in. Let's try it out by refreshing the page and clicking anywhere on the SVG element. The terminal running your Phoenix app should now show something along the lines of:

params: %{
  "altKey" => false,
  "ctrlKey" => false,
  "detail" => 1,
  "metaKey" => false,
  "offsetX" => 549,
  "offsetY" => 227,
  "pageX" => 832,
  "pageY" => 367,
  "screenX" => 832,
  "screenY" => 465,
  "shiftKey" => false,
  "x" => 832,
  "y" => 367
}

It's right there in the offsetX and offsetY where we get our coordinates witin the SVG.

Scaling the data

The coordinates that will come in will be between 0 and 800. Our linear regression algorithm works better with smaller numbers. To make this work we'll need to scale our coordinates down to between 0 and 1. I've added a convenience function called map/5 that takes the original value alongside its original scale and maps it to the second given scale. For example:

map(5, 0, 10, 0, 1) # 0.5

I've wrote this function in the module linreg/math.ex so we can use it both in our Live module to scale the coordinates down as well as in our template to scale the coordinates back up to their original.

defmodule Linreg.Math do
  def map(value, in_min, in_max, out_min, out_max) do
    out_min + (out_max - out_min) * ((value - in_min) / (in_max - in_min))
  end
end

Then I imported the module into our lib/linreg_web.ex module under view_helpers/0:

  defp view_helpers do
    quote do
      # Use all HTML functionality (forms, tags, etc)
      use Phoenix.HTML
      #... snip


      alias LinregWeb.Router.Helpers, as: Routes
+     import Linreg.Math, only: [map: 5]
    end
  end

Importing it in our view_helpers will make it available in both our _live modules as well as our .leex templates, which are exactly the places where we'll need them.

Adding points to our training data

Head back to our regression_live.ex module and find the mount/3 function. This function is executed once (upon hitting the route). This is also the place where we can assign an initial state to our socket. Let's use this hook to initialize an empty model and an empty data struct to collect our datapoints in, like so:

def mount(_params, _sesion, socket) do
  socket =
    socket
    |> assign(model: Linreg.Model.new())
    |> assign(data: Linreg.Data.new())

  {:ok, socket}
end

Now in every handler where we have access to the socket, we also have access to this state in the assigns map. For the next step, we need to go back to our handle_event/3 function and add some logic in and around that function:

  def handle_event("add_point", params, socket) do
    {:noreply, add_point(params, socket)}
  end

  defp add_point(%{"offsetX" => x, "offsetY" => y}, socket) do
    data =
      socket.assigns
      |> Map.get(:data)
      |> Data.add_point(map(x, 0, 800, 0, 1), map(y, 0, 800, 1, 0))

    assign(socket, data: data)
  end

In the add_point/2 function we're grabbing the x and y values and assign them to the data struct stored in our socket.

Also note the call to map/5 in there. We're calling it twice since we're scaling both the X and Y axis down. The X axis from 0 - 800 to 0-1 and the Y axis from 0 - 800 to 1 - 0. We flipped the Y axis so that it Y = 0 is on the bottom of the SVG.

Drawing our input

With our RegressionLive module storing inputs, let's adjust the template so it will render all points it has stored. Open up the regression_live.html.leex template and make some edits:

~ <svg phx-click="add_point" width="800" height="800">
+  <%= for {x, y} <- @data.points do %>
+  <circle
+    cx="<%= map(x, 0, 1, 0, 800) %>"
+    cy="<%= map(y, 0, 1, 800, 0) %>"
+    r="5"
+    fill="#24DA5E"
+  />
+  <% end %>
~ </svg>

It will now iterate over all the points in the data struct and passing them through the map/5 function to map the coordinates to 0 - 800 again. It looks like:

Adding Points

Drawing the predicted line

Now that we have a canvas that renders our collected points, let's hook it up to our linear regression algorithm and let it draw its predicted line.

To draw that line, we need to know the Y value for X = 0 to get the starting point, and Y when x = 800 (the width of our canvas), as our end point, to draw a line between those two points. We'll store both those values on the socket too as start_y and end_y:

  def mount(_params, _sesion, socket) do
    socket =
      socket
      |> assign(model: Linreg.Model.new())
      |> assign(data: Linreg.Data.new())
+     |> assign(start_y: 0)
+     |> assign(end_y: 0)

    {:ok, socket}
  end

In our template we can use these values to draw the line:

  <svg phx-click="add_point" width="800" height="800">
    <%= for {x, y} <- @data.points do %>
    <circle
      cx="<%= map(x, 0, 1, 0, 800) %>"
      cy="<%= map(y, 0, 1, 800, 0) %>"
      r="5"
      fill="#24DA5E"
    />
    <% end %>
+  
+  <line
+    x1="0"
+    y1="<%= map(@start_y, 0, 1, 800, 0) %>"
+    x2="800"
+    y2="<%= map(@end_y, 0, 1, 800, 0) %>"
+    stroke-width="1"
+    stroke="#2477DA"
+  />
  </svg>

We've added a line element which takes two X and Y pairs to indicate the start and end points of the line. As you can see these points are passed through our map/5 function as well before using them in the template.

Learning

When adding new points we like our model to run the training adjusting its weights and on its turn adjusting the predicted line. To run the training, add the following two functions to our RegressionLive module:

defp learn(%{assigns: %{data: data, model: model}} = socket) do
  if length(data.points) >= 2 do
    model = Model.train(model, data, learning_rate: 0.1, epochs: 500)
    assign(socket, model: model)
  else
    socket
  end
end

defp update_prediction(%{assigns: %{model: model}} = socket) do
  socket
  |> assign(start_y: Model.predict(model, 0))
  |> assign(end_y: Model.predict(model, 1))
end

The first one: learn/1 will take the socket and pattern match the training data and the model from the socket. Then, only if there's two or more points in the training set, it'll call the Model.train/3 function. In this case we're calling it with a learning rate of 0.1 and we'll train it for 500 epochs, but feel free to play with these numbers and observe the output change.

The other function update_predicton/1 simply takes the socket to pattern match the model out of there, and assigns the start_y and end_y values in the socket based on the updated model's prediction for X = 0 and X = 1.

Now to use these functions, let's revisit our add_point/2 function one last time:

defp add_point(%{"offsetX" => x, "offsetY" => y}, socket) do
  data =
    socket.assigns
    |> Map.get(:data)
    |> Data.add_point(map(x, 0, 800, 0, 1), map(y, 0, 800, 1, 0))

- assign(socket, data: data)
+ socket
+ |> assign(data: data)
+ |> learn()
+ |> update_prediction()
end

Done

And we're done! We've built a linear regression algorithm in Elixir and made it interactive and visual using LiveView!

That's a wrap!

You can find the full code on GitHub. Feel free to play around with it and make it better! Thanks for reading :)

crosspost from https://tiemenwaterreus.com/posts/linear-regression-elixir-phoenix-liveview-ii/

Top comments (1)

Collapse
 
stanbright profile image
Stan Bright

Great job, mate! Thanks for sharing your experience!