Meow 🐈
Mix.install([
{:meow, github: "jonatanklosko/meow", ref: "bb1c0467e82a3dd4ce24a8d2ac384a4c410a0c44"},
{:nx, "~> 0.3.0"},
{:exla, "~> 0.3.0"},
{:maplibre, "~> 0.1.2"},
{:kino_maplibre, "~> 0.1.3"},
{:kino_vega_lite, "~> 0.1.4"}
])
Nx.Defn.global_default_options(compiler: EXLA)
Primary objectives
Example: One-Max
One-Max is a toy problem where each gene is either 0 or 1 and the objective is to maximise the number of ones.
defmodule OneMax do
import Nx.Defn
def size, do: 100
defn evaluate(genomes) do
Nx.sum(genomes, axes: [1])
end
end
Nx.tensor([
[0, 1, 1, 1, 0],
[1, 0, 0, 1, 0]
])
|> OneMax.evaluate()
In this case we will use a straightforwrad algorithm with linear steps:
graph TD;
1[Population N];
2[Select parents];
3[Crossover];
4[Mutate];
5[Population N+1];
1-->2;
2-->3;
3-->4;
4-->5;
algorithm =
Meow.objective(&OneMax.evaluate/1)
|> Meow.add_pipeline(
MeowNx.Ops.init_binary_random_uniform(100, OneMax.size()),
Meow.pipeline([
MeowNx.Ops.selection_tournament(1.0),
MeowNx.Ops.crossover_uniform(0.5),
MeowNx.Ops.mutation_bit_flip(0.001),
MeowNx.Ops.log_best_individual(),
Meow.Ops.max_generations(100)
])
)
report = Meow.run(algorithm)
report |> Meow.Report.format_summary() |> IO.puts()
Example: Rastrigin
$$ f(\textbf{x}) = \sum_{i=0}^n \left[10 + x_i^2 - 10 \cos({2 \pi x_i}) \right] $$
defmodule Rastrigin do
import Nx.Defn
def size, do: 100
@two_pi 2 * :math.pi()
defn evaluate(genomes) do
sums = (10 + genomes ** 2 - 10 * Nx.cos(@two_pi * genomes)) |> Nx.sum(axes: [1])
-sums
end
end
Here we introduce elitism, we want to preserve the best individuals unchanged in every generation.
graph TD;
1[Population N];
2[Select top 20%];
3["Select parents (80%)"];
4[Crossover];
5[Mutation];
6[Population N+1];
1-->2;
2-->6;
1-->3;
3-->4;
4-->5;
5-->6;
algorithm =
Meow.objective(&Rastrigin.evaluate/1)
|> Meow.add_pipeline(
MeowNx.Ops.init_real_random_uniform(100, Rastrigin.size(), -5.12, 5.12),
Meow.pipeline([
Meow.Ops.split_join([
Meow.pipeline([
MeowNx.Ops.selection_natural(0.2)
]),
Meow.pipeline([
MeowNx.Ops.selection_tournament(0.8),
MeowNx.Ops.crossover_blend_alpha(0.5),
MeowNx.Ops.mutation_shift_gaussian(0.001)
])
]),
MeowNx.Ops.log_best_individual(),
MeowNx.Ops.log_metrics(%{fitness_max: &MeowNx.Metric.fitness_max/2}, interval: 10),
Meow.Ops.max_generations(3_000)
])
)
report = Meow.run(algorithm)
report |> Meow.Report.format_summary() |> IO.puts()
Meow.Report.plot_metrics(report)
Example: shortest route
visualize_route = fn coordinates ->
coordinates = for {lat, lng} <- coordinates, do: {lng, lat}
MapLibre.new(center: {19.938068, 50.058023}, zoom: 13, style: :street)
|> MapLibre.add_source("route",
type: :geojson,
data: [type: "Feature", geometry: [type: "LineString", coordinates: coordinates]]
)
|> MapLibre.add_layer(id: "points", type: :circle, source: "route", paint: [circle_radius: 6])
|> MapLibre.add_layer(id: "route", type: :line, source: "route", paint: [line_width: 2])
end
coordinates =
Enum.shuffle([
{50.066405, 19.939034},
{50.066254, 19.935942},
{50.063473, 19.932803},
{50.063258, 19.927582},
{50.062526, 19.925365},
{50.061810, 19.925666},
{50.062211, 19.927711},
{50.059787, 19.927925},
{50.060585, 19.932391},
{50.058684, 19.933070},
{50.055378, 19.934831},
{50.055300, 19.937495},
{50.054093, 19.939161},
{50.053298, 19.937308},
{50.052444, 19.935140},
{50.051337, 19.935554},
{50.051806, 19.937508},
{50.050745, 19.938388},
{50.049891, 19.935064},
{50.049268, 19.932833}
])
visualize_route.(coordinates)
defmodule Geodesy do
import Nx.Defn
@doc """
Calculates pairwise distance between geographic points in kilometres.
"""
defn distance_matrix(lat, lng) do
lat1 = Nx.new_axis(lat, 0)
lng1 = Nx.new_axis(lng, 0)
lat2 = Nx.new_axis(lat, 1)
lng2 = Nx.new_axis(lng, 1)
# See https://www.movable-type.co.uk/scripts/latlong.html#equirectangular
x = to_radians(lng2 - lng1) * Nx.cos(to_radians(lat1 + lat2) / 2)
y = to_radians(lat2 - lat1)
Nx.sqrt(x ** 2 + y ** 2) * 6371
end
@pi :math.pi()
defnp to_radians(deg) do
deg * @pi / 180
end
end
First, we compute a matrix with distance between each pair of points.
route_size = length(coordinates)
{lat, lng} = Enum.unzip(coordinates)
distances = Geodesy.distance_matrix(Nx.tensor(lat), Nx.tensor(lng))
Next, we define our objective.
defmodule Problem do
import Nx.Defn
defn evaluate(permutations, distances) do
edges =
Nx.stack(
[
permutations[[0..-1//1, 0..-2//1]],
permutations[[0..-1//1, 1..-1//1]]
],
axis: -1
)
total_distance = distances |> Nx.gather(edges) |> Nx.sum(axes: [1])
-total_distance
end
end
In this case, our algorithm will involve multiple cooperating populations!
algorithm =
Meow.objective(&Problem.evaluate(&1, distances))
|> Meow.add_pipeline(
MeowNx.Ops.Permutation.init_permutation_random(200, route_size),
Meow.pipeline([
Meow.Ops.split_join([
Meow.pipeline([
MeowNx.Ops.selection_natural(0.2)
]),
Meow.pipeline([
MeowNx.Ops.selection_tournament(0.8),
MeowNx.Ops.Permutation.crossover_order(),
MeowNx.Ops.Permutation.mutation_inversion(0.5)
])
]),
Meow.Ops.emigrate(MeowNx.Ops.selection_natural(5), &Meow.Topology.ring/2, interval: 10),
Meow.Ops.immigrate(&MeowNx.Ops.selection_natural(&1), interval: 10),
MeowNx.Ops.log_best_individual(),
MeowNx.Ops.log_metrics(%{fitness_max: &MeowNx.Metric.fitness_max/2}, interval: 10),
Meow.Ops.max_generations(100)
]),
duplicate: 6
)
report = Meow.run(algorithm)
report |> Meow.Report.format_summary() |> IO.puts()
Meow.Report.plot_metrics(report)
best_permutation =
report.population_reports
|> Enum.map(& &1.population.log.best_individual)
|> Enum.max_by(& &1.fitness)
|> Map.get(:genome)
|> Nx.to_flat_list()
coordinates_in_order = for idx <- best_permutation, do: Enum.at(coordinates, idx)
visualize_route.(coordinates_in_order)
Performance evaluation
Comparison against other frameworks
Comparison with EASEA (GPU)
Design
Operations API
Numerical definition:
defn bit_flip(genomes, opts \\ []) do
shape = Nx.shape(genomes)
# Mutate each gene separately with the given probability
mutate? = Nx.random_uniform(shape) |> Nx.less(opts[:probability])
mutated = Nx.subtract(1, genomes)
Nx.select(mutate?, mutated, genomes)
end
Operation metadata:
def mutation_bit_flip(probability) do
%Op{
name: "[Nx] Mutation: bit flip",
requires_fitness: false,
invalidates_fitness: true,
in_representations: [MeowNx.binary_representation()],
impl: fn population, _ctx ->
new_genomes = bit_flip(genomes, probability: probability)
%{population | genomes: new_genomes}
end
}
end
Distribution
Meow.Distribution.init_from_cli_args!(fn nodes ->
report = Meow.run(algorithm, nodes: nodes)
end)
Leader node:
$ elixir --name leader@127.0.0.1 script.exs leader worker1@127.0.0.1 worker2@127.0.0.1
Worker nodes:
$ elixir --name worker1@127.0.0.1 script.exs worker
$ elixir --name worker2@127.0.0.1 script.exs worker
In retrospect
Opportunities
-
Compiling the whole algorithm as a single computation.
-
Nx.Random
-
Automatic vectorization.
-
General abstraction for distributed Nx.
Key points
-
Nx brings exciting domains to Elixir.
-
Nx is already solid and keeps growing.