Palabras para Julia ( Parte 2/n)

Julia
ciencia de datos
2021
Author

jlcr

Published

August 16, 2021

Introducción

¿Qué os parecería tener un modelo guardado y un binario en linux que tomando como parámetros el modelo y el dataset a predecir guardara las predicciones en un csv?

Y todo eso que funcione en cualquier Linux, de forma que puedas copiar esa aplicación de un Ubuntu a un EC2 con amazon linux (un centos) y que funcione igual sin tener que tener Julia instalado en el EC2.

Y no estoy hablando de tener un docker o tener un entorno de conda dónde lo despliegas y tu script dónde se predice necesita ser interpretado, sino de una aplicación compilada dónde tiene el runtime de Julia y todo lo necesario para correr. De hecho la estructura de esa aplicación sería algo así.

 $ ▶ tree -L 1 ../bin_blog
../bin_blog
├── artifacts
├── bin
└── lib

Bueno, pues vamos a ver cómo se consigue eso utilizando el paquete PackageCompiler En primer lugar algunas consideraciones sobre latencia y precompilados que podéis ver en el video de Kristoffer Carlson.

  • Julia tiene los paquetes precompilados, por lo que cuando arrancas el pc y haces using paquete tarda un rato.
  • Una vez compilado, la primera vez que lo invocas tarda un tiempo.
  • Aunque ya hayas hecho using paquete la primera vez que usas una función también tiene que compilarla y tarda otro rato.

por ejemplo, vemos que la primera vez que hago using Plots tarda unos 3 segundos, o que la primera vez que uso la función plot también tarda, pero la siguiente vez es muy rápido.

julia> @time using Plots
  3.267061 seconds (7.93 M allocations: 551.989 MiB, 3.89% gc time, 0.17% compilation time)

julia> @time using Plots
  0.666359 seconds (665.66 k allocations: 37.314 MiB, 6.76% gc time, 99.99% compilation time)


julia> @time p = plot(rand(2,2))
  2.436100 seconds (3.11 M allocations: 186.676 MiB, 7.61% gc time, 57.23% compilation time)
[ Info: Precompiling GR_jll [d2c73de3-f751-5644-a686-071e5b155ba9]

julia> @time p = plot(rand(2,2))
  0.000883 seconds (3.93 k allocations: 228.672 KiB)

Pero, ¿no era una de las características de Julia su velocidad, cómo podemos apañar esto?. Pues hay varias formas.

  • Crear un archivo startup.jl en \.julia\config\ dónde escribamos lo que queremos que se cargue al iniciar julia.
  • Crear una sysimage de julia con PackageCompiler de forma que podamos hacer algo como julia --sysimage mi_sysimage.so y se inicie Julia con los paquetes que queramos ya compilados y cargados.

El paquete PackageCompiler permite también crear una aplicación de forma que crea una sysimage de julia junto con un script con una función main que es la que se ejecuta al llamar al binario que crea. La ventaja de esta aproximación es que podemos crear un binario que funcione en cualquier linux aunque no tengamos Julia instalado.

Objetivo

El objetivo es construir un “Motor de Modelos” (recuerdos a mi amigo Roberto Sancho) que funciones en cualquier linux, y que dado un modelo previamente entrenado y la ruta de un fichero con los datos, haga la predicción del modelo y escriba un csv con el resultado.

Al final se podría usar de la siguiente forma

motor_modelos modelo_entrenado.jlso datos_to_predict.csv resultado.csv

En las pruebas que he hecho, para predecir un fichero de 5 millones de filas y escribir el csv con el resultado de la predicción de un modelo randomForest ha tardado unos 15 segundos en todo el proceso.

Creando el entorno necesario

Lo primero que tenemos que hacer es seguir el proceso como para crear un paquete en julia. Iniciamos julia en el REPL y entrando en el modo paquete con ] utilizamos generate

(@v1.6) pkg> generate decision_tree_app
  Generating  project decision_tree_app:
    decision_tree_app/Project.toml
    decision_tree_app/src/decision_tree_app.jl

Esto crea el directorio decision_tree_app así como un Project.toml dónde se va a ir guardando la referencia y las versiones de las librerías que usemos, y también crea el fichero src/decision_tree_app.jl con la estructura mínima.

╭─ jose @ jose-PROX15 ~

╰─ $ ▶ cat decision_tree_app/src/decision_tree_app.jl 
module decision_tree_app

greet() = print("Hello World!")

end # module

Pues sobre esta base es la que vamos a trabajar. Ahora tenemos que activar el entorno con

(@v1.6) pkg> activate .
  Activating environment at `~/decision_tree_app/Project.toml`
 

De esta forma cada vez que añadamos un paquete con add nombrepquete se queda guardado la referencia en el el Project.toml y se creará un Manifest.toml, estos dos archivos son los que nos servirán para reproducir el mismo entorno en otro sitio, equivalente a un requirements en python.

Crear la aplicación

En el paquete PackageCompiler existe la función create_app que tomando como argumentos, el directorio de la aplicación, directorio dónde compilar y uno o varios ficheros de ejemplo del flujo que se va a realizar, creará la apliación compilada.

Fichero de precompilación

Es importante tener un fichero de precompilación que sea ejemplo simple de lo que tiene que hacer la aplicación. A saber, leer un modelo, leer unos datos, predecir y escribir el resultado.

Para eso, entrenamos un modelo sobre iris, y guardamos el modelo

Fichero train.jl


# Training model julia
using CSV,CategoricalArrays, DataFrames, MLJ


df1 = CSV.read("data/iris.csv", DataFrame)

const target =  CategoricalArray(df1[:, :Species])
const X = df1[:, Not(:Species)]


Tree = @load RandomForestClassifier pkg=DecisionTree
tree = Tree(n_trees = 20)
tree.max_depth = 3

mach = machine(tree, X, target)

train, test = partition(eachindex(target), 0.7, shuffle=true)

fit!(mach, rows=train)


MLJ.save("mimodelo.jlso", mach, compression=:gzip)

Y una vez que tenemos el modelo guardado creamos el fichero de precompilación que pasaremos como argumento a create_app

Fichero precomp_file.jl en directorio src

En este fichero al llamar a las funciones CSV.read, predict, o CSV.write se consigue que al crear la aplicación compilada esas funciones se compilen y la latencia sea mínima.

using MLJ, CSV, DataFrames, MLJDecisionTreeInterface

# uso rutas absolutas
df1 = CSV.read("/home/jose/Julia_projects/decision_tree_app/data/iris.csv", DataFrame)
X = df1[:, Not(:Species)]

predict_only_mach = machine("/home/jose/Julia_projects/decision_tree_app/mimodelo.jlso")

= predict(predict_only_mach, X) 


predict_mode(predict_only_mach, X)

niveles = levels.(ŷ)[1]

res = pdf(ŷ, niveles) # con pdf nos da la probabilidad de cada nivel
res_df = DataFrame(res,:auto)
rename!(res_df, niveles)

CSV.write("/home/jose/Julia_projects/decision_tree_app/data/predicciones.csv", res_df)

Cuando compilamos una aplicación con PackageCompiler lo que se ejecuta es la función julia_main que se encuentre en el módulo que creamos con el mismo nombre que el nombre de la aplicación.

Fichero decision_tree_app.jl en src

module decision_tree_app

using MLJ, CSV, DataFrames, MLJDecisionTreeInterface

function julia_main()::Cint
    try
        real_main()
    catch
        Base.invokelatest(Base.display_error, Base.catch_stack())
        return 1
    end
    return 0
end

# ARGS son los argumentos pasados por consola 

function real_main()
    if length(ARGS) == 0
        error("pass arguments")
    end

# Read model
    modelo = machine(ARGS[1])
# read data. El fichero qeu pasemos tiene que tener solo las X.(con su nombre)
    X = CSV.read(ARGS[2], DataFrame, tasks=10)
# Predict    
= predict(modelo, X)            # predict
    niveles = levels.(ŷ)[1]           # get levels of target
    res = pdf(ŷ, niveles)             # predict probabilities for each level
    
    res_df = DataFrame(res,:auto)     # convert to DataFrame
    rename!(res_df, niveles)          # Column rename
    CSV.write(ARGS[3], res_df)        # Write in csv
end


end # module

Así nos queda la estructura

 tree -L 1 src/
src/
├── decision_tree_app.jl
├── precomp_file.jl
└── train.jl

Ahora ya podemos compilar la aplicación


using PackageCompiler

create_app("../decision_tree_app", "../bin_blog", precompile_execution_file="../decision_tree_app/src/precomp_file.jl", force=true, filter_stdlibs = true)

Y después de unos 30 minutos ya tenemos en el directorio bin_blog todo lo necesario, el runtime de julia embebido, las librerías compiladas , etcétera, de forma que copiando esa estructura en otro ordenador (con linux) ya funcionaría nuestra app sin tener Julia instalado.


tree -L 1 bin_blog/
bin_blog/
├── artifacts
├── bin
└── lib

Por ejemplo en lib tenemos

total 272
drwxrwxr-x 3 jose jose   4096 ago 14 10:37 ./
drwxrwxr-x 5 jose jose   4096 ago 14 10:37 ../
drwxrwxr-x 2 jose jose   4096 ago 14 10:37 julia/
lrwxrwxrwx 1 jose jose     15 ago 14 10:37 libjulia.so -> libjulia.so.1.6*
lrwxrwxrwx 1 jose jose     15 ago 14 10:37 libjulia.so.1 -> libjulia.so.1.6*
-rwxr-xr-x 1 jose jose 266232 ago 14 10:37 libjulia.so.1.6*

y en lib/julia tiene este aspecto

drwxrwxr-x 2 jose jose     4096 ago 14 10:37 ./
drwxrwxr-x 3 jose jose     4096 ago 14 10:37 ../
lrwxrwxrwx 1 jose jose       15 ago 14 10:37 libamd.so -> libamd.so.2.4.6*
lrwxrwxrwx 1 jose jose       15 ago 14 10:37 libamd.so.2 -> libamd.so.2.4.6*
-rwxr-xr-x 1 jose jose    39059 ago 14 10:37 libamd.so.2.4.6*
lrwxrwxrwx 1 jose jose       18 ago 14 10:37 libatomic.so.1 -> libatomic.so.1.2.0*
-rwxr-xr-x 1 jose jose   147600 ago 14 10:37 libatomic.so.1.2.0*
lrwxrwxrwx 1 jose jose       15 ago 14 10:37 libbtf.so -> libbtf.so.1.2.6*
lrwxrwxrwx 1 jose jose       15 ago 14 10:37 libbtf.so.1 -> libbtf.so.1.2.6*
-rwxr-xr-x 1 jose jose    13108 ago 14 10:37 libbtf.so.1.2.6*
lrwxrwxrwx 1 jose jose       16 ago 14 10:37 libcamd.so -> libcamd.so.2.4.6*
lrwxrwxrwx 1 jose jose       16 ago 14 10:37 libcamd.so.2 -> libcamd.so.2.4.6*
-rwxr-xr-x 1 jose jose    43470 ago 14 10:37 libcamd.so.2.4.6*
-rwxr-xr-x 1 jose jose    28704 ago 14 10:37 libccalltest.so*
-rwxr-xr-x 1 jose jose    39128 ago 14 10:37 libccalltest.so.debug*
lrwxrwxrwx 1 jose jose       19 ago 14 10:37 libccolamd.so -> libccolamd.so.2.9.6*
lrwxrwxrwx 1 jose jose       19 ago 14 10:37 libccolamd.so.2 -> libccolamd.so.2.9.6*
-rwxr-xr-x 1 jose jose    47652 ago 14 10:37 libccolamd.so.2.9.6*
lrwxrwxrwx 1 jose jose       20 ago 14 10:37 libcholmod.so -> libcholmod.so.3.0.13*
lrwxrwxrwx 1 jose jose       20 ago 14 10:37 libcholmod.so.3 -> libcholmod.so.3.0.13*
-rwxr-xr-x 1 jose jose  1005880 ago 14 10:37 libcholmod.so.3.0.13*
lrwxrwxrwx 1 jose jose       18 ago 14 10:37 libcolamd.so -> libcolamd.so.2.9.6*
lrwxrwxrwx 1 jose jose       18 ago 14 10:37 libcolamd.so.2 -> libcolamd.so.2.9.6*
-rwxr-xr-x 1 jose jose    31250 ago 14 10:37 libcolamd.so.2.9.6*
lrwxrwxrwx 1 jose jose       16 ago 14 10:37 libcurl.so -> libcurl.so.4.7.0*
lrwxrwxrwx 1 jose jose       16 ago 14 10:37 libcurl.so.4 -> libcurl.so.4.7.0*
-rwxr-xr-x 1 jose jose   654080 ago 14 10:37 libcurl.so.4.7.0*
-rwxr-xr-x 1 jose jose    22120 ago 14 10:37 libdSFMT.so*
-rwxr-xr-x 1 jose jose   758680 ago 14 10:37 libgcc_s.so.1*
lrwxrwxrwx 1 jose jose       20 ago 14 10:37 libgfortran.so.4 -> libgfortran.so.4.0.0*

y en bin

total 245180
drwxrwxr-x 2 jose jose      4096 ago 14 10:50 ./
drwxrwxr-x 5 jose jose      4096 ago 14 10:37 ../
-rwxrwxr-x 1 jose jose     17928 ago 14 10:50 decision_tree_app*
-rwxrwxr-x 1 jose jose 251030272 ago 14 10:50 decision_tree_app.so*

Utilizando la aplicación

Lo primero que comprobamos es si la aplicación funciona con el modelo que tenemos sobre iris.

Intentamos predecir un fichero tal que así

head test_to_predict.csv 
Sepal.Length,Sepal.Width,Petal.Length,Petal.Width
5.1,3.5,1.4,0.2
4.9,3,1.4,0.2
4.7,3.2,1.3,0.2
4.6,3.1,1.5,0.2
5,3.6,1.4,0.2
5.4,3.9,1.7,0.4
4.6,3.4,1.4,0.3
5,3.4,1.5,0.2
4.4,2.9,1.4,0.2

Ahora ejecutamos la app pasándole el modelo guardado en train.jl y el csv

╰─ $ ▶ time ../bin_blog/bin/decision_tree_app mimodelo.jlso test_to_predict.csv predicho.csv

real    0m2,046s
user    0m2,344s
sys 0m0,581s
╰─ $ ▶ head -20 predicho.csv 
setosa,versicolor,virginica
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
1.0,0.0,0.0
0.9,0.1,0.0
0.95,0.05,0.0
1.0,0.0,0.0
1.0,0.0,0.0
0.95,0.05,0.0

Uso como motor para predecir.

Una vez tenemos la app queremos utilizarla con otros modelos y otros datos sin necesidad de tener que compilar de nuevo.

Lo primero es usar las mismas versiones de las librerías que hemos usado en la app. Para eso copiamos los archivos Project.toml y Manifest.toml en otro directorio y activamos el entorno con activate . e instalamos los paquetes con instanstiate


 $ ▶ cd entorno_modelos/
╭─ jose @ jose-PROX15 ~/Julia_projects/entorno_modelos

╰─ $ ▶ julia 
               _
   _       _ _(_)_     |  Documentation: https://docs.julialang.org
  (_)     | (_) (_)    |
   _ _   _| |_  __ _   |  Type "?" for help, "]?" for Pkg help.
  | | | | | | |/ _` |  |
  | | |_| | | | (_| |  |  Version 1.6.2 (2021-07-14)
 _/ |\__'_|_|_|\__'_|  |  Official https://julialang.org/ release
|__/                   |

(@v1.6) pkg> activate .
  Activating environment at `~/Julia_projects/entorno_modelos/Project.toml`

(decision_tree_app) pkg> instantiate

Y ya podemos entrenar un nuevo modelo. En este caso voy a entrenar un modelo sobre los datos del precio de las viviendas en Boston, pero dónde he creado variables categórica que diferencie entre si el precio es mayor que 20 o menor, variable medv_20 con niveles (G20, NG20)

 $ ▶ head data/boston.csv 
"crim","zn","indus","chas","nox","rm","age","dis","rad","tax","ptratio","lstat","medv_20"
0.00632,18,2.31,0,0.538,6.575,65.2,4.09,1,296,15.3,4.98,"G20"
0.02731,0,7.07,0,0.469,6.421,78.9,4.9671,2,242,17.8,9.14,"G20"
0.02729,0,7.07,0,0.469,7.185,61.1,4.9671,2,242,17.8,4.03,"G20"
0.03237,0,2.18,0,0.458,6.998,45.8,6.0622,3,222,18.7,2.94,"G20"
0.06905,0,2.18,0,0.458,7.147,54.2,6.0622,3,222,18.7,5.33,"G20"
0.02985,0,2.18,0,0.458,6.43,58.7,6.0622,3,222,18.7,5.21,"G20"
0.08829,12.5,7.87,0,0.524,6.012,66.6,5.5605,5,311,15.2,12.43,"G20"
0.14455,12.5,7.87,0,0.524,6.172,96.1,5.9505,5,311,15.2,19.15,"G20"
0.21124,12.5,7.87,0,0.524,5.631,100,6.0821,5,311,15.2,29.93,"NG20"

Y nuestro fichero de entrenamiento es el siguiente. Dejo solo lo de entrenar y guardar el modelo, otro día vemos la validación cruzada etc..

# Training model julia
using CSV,CategoricalArrays, DataFrames, MLJ


df1 = CSV.read("data/boston.csv", DataFrame)

const target =  CategoricalArray(df1[:, :medv_20])
const X = df1[:, Not(:medv_20)]

Tree = @load RandomForestClassifier pkg=DecisionTree
tree = Tree(n_trees = 20)
tree.max_depth = 5


mach = machine(tree, X, target)

train, test = partition(eachindex(target), 0.7, shuffle=true)

fit!(mach, rows=train)


MLJ.save("boston_rf.jlso", mach, compression=:gzip)

Y ya podemos usar nuestra aplicación compilada para predecir con el modelo que acabamos de entrenar. Para simular más un proceso real, usamos el conjunto de datos de boston pero con 5 millones de filas.

Fichero sin la variable a predecir

╰─ $ ▶ head -10  boston_to_predict.csv 
crim,zn,indus,chas,nox,rm,age,dis,rad,tax,ptratio,lstat
0.00632,18,2.31,0,0.538,6.575,65.2,4.09,1,296,15.3,4.98
0.02731,0,7.07,0,0.469,6.421,78.9,4.9671,2,242,17.8,9.14
0.02729,0,7.07,0,0.469,7.185,61.1,4.9671,2,242,17.8,4.03
0.03237,0,2.18,0,0.458,6.998,45.8,6.0622,3,222,18.7,2.94
0.06905,0,2.18,0,0.458,7.147,54.2,6.0622,3,222,18.7,5.33
0.02985,0,2.18,0,0.458,6.43,58.7,6.0622,3,222,18.7,5.21
0.08829,12.5,7.87,0,0.524,6.012,66.6,5.5605,5,311,15.2,12.43
0.14455,12.5,7.87,0,0.524,6.172,96.1,5.9505,5,311,15.2,19.15
0.21124,12.5,7.87,0,0.524,5.631,100,6.0821,5,311,15.2,29.93

╰─ $ ▶ wc -l  boston_to_predict.csv 
5060001 boston_to_predict.csv

Y ahora utilizamos nuestreo “Motor de Modelos” . Y en mi pc, tarda en predecir y escribir el resultado en torno a los 15 segundos.

╰─ $ ▶ time ../bin_blog/bin/decision_tree_app boston_rf.jlso boston_to_predict.csv res.csv

real    0m14,080s
user    0m28,949s
sys 0m3,442s
╰─ $ ▶ wc -l res.csv 
5060001 res.csv
╭─ jose @ jose-PROX15 ~/Julia_projects/entorno_modelos

╰─ $ ▶ head -10 res.csv 
G20,NG20
0.95,0.05
0.95,0.05
1.0,0.0
1.0,0.0
1.0,0.0
1.0,0.0
0.85,0.15
0.4,0.6
0.25,0.75

Conclusión

Esto es sólo un ejemplo de como crear una aplicación compilada con Julia, en este caso para temas de “machín lenin”, pero podría ser para otra cosa.

Como ventaja, que podemos crear un tar.gz con toda la estructura creada en directorio bin_blog y ponerlo en otro linux y qué funcione sin necesidad de que sea la misma distribución de linux ni de que esté Julia instalado. Esto podría ser útil en entornos productivos en los que haya restricciones a la hora de instalar software.

Aún tengo que explorar como leer y escribir ficheros de s3 con AWSS3.jl y más cosas relacionadas.

Hasta otra.