Un blog personal

Reescribiendo métricas de distancia en Elixir.

· Esteban Zapata Rojas

¡Hola a todos! 👋

Hace no mucho, me topé con un pequeño blog post de Vector Logic, donde implementaban varias métricas de distancia en Ruby, lo que me pareció demasiado interesante. Esta implementación también viene con una pequeña lista de tests escritos en Rspec, por lo que ahí fue cuando se me prendió el bombillo: ¡Reescribirlo en Elixir!

¿Por qué en Elixir? bueno, primero porque estoy aprendiendo a programar en Elixir y adentrarme un poco más en el mundo del functional programming o programación funcional y segundo porque me permite realizar un pequeño ejercicio donde escribo pruebas para un código en este nuevo lenguaje, con un módulo llamado ExUnit.

¡Así que pues, vamos a ver lo que hice! 😎

Las métricas

El post describe la implementación de siete métricas que son muy importantes en muchos campos, en especial en el machine learning:

  1. Distancia euclideana (Wikipedia).
  2. Distancia Manhattan (Wikipedia).
  3. Distancia de Chebyshev (Wikipedia).
  4. Distancia de Minkowski (Wikipedia - Inglés).
  5. Distancia de Hamming (Wikipedia).
  6. Similutd coseno (Wikipedia).
  7. Índice de Jaccard (Wikipedia).

No entraré en detalles sobre cada una de ellas (¡Aunque recomendado ese rabbit hole en Wikipedia! 🐰), pero estas métricas se usan mucho para calcular distancias y diferencias entre vectores. Es decir, se utilizan para saber si dos conjuntos de datos (con sus respectivas restricciones y condiciones), presentan similitud o diferencias y qué tan variables son el uno del otro.

Es perfecto para expresar errores o evaluar la efectividad de un algoritmo de aprendizaje automático.

A modo de ejemplo, con el índice de Jaccard y la métrica de distancia de Jaccard, es posible expresar qué tan interconectado está una cuenta de una red social respecto a otra, dando un cálculo algo rudimentario de lo que podríamos llamar influenciador 🤯.

Implementación en Elixir

Estuvo interesante el reto, en parte porque Elixir, al ser un lenguaje funcional, te obliga a pensar diferente, porque todo elemento es inmutable. No vas a poder cambiar el valor de una variable o modificar en el mismo elemento un valor o una llave, pero sin duda todo termina siendo más expresivo.

Veamos por ejemplo el cálculo de la distancia euclidiana:

# ruby
def self.euclidean_distance(a,b)
return unless a.any? && (a.size == b.size)

diff_squared = (0..a.size-1).reduce(0) do |sum, i|
  sum + (a[i] - b[i])**2
end
Math.sqrt(diff_squared)
end

Si notan, es simplemente el teorema de pitágoras, pero generalizado para una dimensión N expresado a través de vectores, o enumerables en ruby. Comparemos el mismo cálculo con la implementación en Elixir:

# Añadimos una guardia o condición para la utilizar la función,
# en este caso sólo se puede usar si tanto A como B
# tienen elementos (> 0) y ambos tienen el mismo tamaño.
def euclidean(a, b) when length(a) > 0 and length(b) > 0 and length(a) == length(b) do
# Hacemos un "Zip" de los dos vectores:
#   A = [1, 2, 3], B = [4, 5, 6] => A(zip)B = [{1, 4}, {2, 5}, {3, 6}]
# Luego, enviamos el cálculo a una función reduce, que nos permite pasar
# de un vector o lista a un número.
# Una vez calculado la distancia: `acumulator + (x - y) ** 2`,
# el resultado le sacamos la raiz cuadrada.
Stream.zip(a, b)
    |> Enum.reduce(0, fn {x, y}, accumulator ->
        accumulator + (x - y) ** 2
        end)
    |> :math.sqrt()
end

¡wow! qué cambio. Lo primero que salta la vista es que ya no es necesario definir early returns o return guards dentro del método, sino que ahora fuerzas la validación a la firma (signature) de la función que lo haga antes de hacer cualquier cosa. Es súper poderosa esta opción, además de ser demasiado flexible también. Es una de las opciones que más me gustan de elixir.

Luego, ya sólo queda implementar la función como tal y asegurarnos que devuelva el cálculo final. Aunque no es muy evidente, en la versión de Ruby, se está mutando o cambiando el acumulador sum, mientras que en Elixir, sólo se está utilizando el último valor retornado de la iteración anterior.

Veamos otro ejemplo en Ruby, para que sea más clara esta diferencia. Uso a propósito el método each_with_object para mostrar que la última línea no es el resultado de la iteración.

elements = (1..10).to_a
accumulator = []

elements.each_with_object(accumulator) do |i, memo|
  memo << i * 2
end

y su resultado

=> [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]
irb(main):008:0> accumulator
=> [2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

Como vemos, memo cambia a lo largo de la ejecución y terminamos con un objeto completamente diferente almacenado en accumulator. Si fuera a escribir lo mismo en Elixir, sería algo así:

elements = (1..10) |> Enum.take(10) # Grab 10 elements from range and convert it to a list
accumulator = []

# The | operator works as a prepend operator for lists and certain Enumerable elements.
Enum.reduce(elements, accumulator, fn x, acc -> [x * 2 | acc] end)

y su salida:

[20, 18, 16, 14, 12, 10, 8, 6, 4, 2]
iex(4)> accumulator
[]

como vemos, a diferencia de Ruby, Elixir no cambió accumulator, por lo que para tener el mismo resultado tendríamos que hacer algo así:

accumulator =
    Enum.reduce(elements, accumulator, fn x, acc -> [x * 2 | acc] end)
    |> Enum.reverse() # We have to reverse as we are prepending

Y su respectiva salida:

iex(6)> accumulator
[2, 4, 6, 8, 10, 12, 14, 16, 18, 20]

¿Interesante, no? En Elixir, es más económico acceder a las cabezas de las listas que al final, por la forma en que está implementada la estructura en Erlang, por eso es que tenemos que hacer Enum.reverse.

En cuanto a los tests, pues me pareció que Elixir ofrece un módulo relativamente robusto para realizar pruebas, pero no me siento todavía cómodo usándola, ya que estoy pensando más en modo Rspec con su flexibilidad y facilidad para realizar verificación bastante complejas ¿Usaría ExUnit? ¡Claro que sí! ¿cambiaría si encuentro una mejor opción? ¡Sin duda alguna! 😁.

Conclusión

Aprender un lenguaje de programación con un paradigma tan diferente como es el funcional, es complejo y más porque Elixir está inspirado en Ruby, lo que hace que sea un poco más dificil salirse de ciertos hábitos del paradigma objetual, lo que hace un poco frustrante si no se tiene la suficiente paciencia 😅.

Creo que Elixir tiene un futuro interesante en el desarrollo de aplicaciones web y en ciertos nichos como sistemas distribuidos y de alta complejidad, autómatas y aprendizaje automático, por lo que es sin duda alguna una excelente alternativa a Ruby y su excelente ecosistema.

Y para evitar la espera, acá en el siguiente Gist puedes encontrar la implementación que hice en Elixir de las diferentes mediciones.

¡Chau! 🍻