Álgebra lineal y computación numérica en Julia: arrays, matrices y BLAS nativo

La computación numérica es el territorio donde Julia nació y donde todavía brilla más. No hay capas de Python encima de C: el código Julia que escribe un científico ya es código nativo. Cuando multiplicas dos matrices con A * B, Julia llama directamente a DGEMM de BLAS sin intermediarios. El resultado es que los bucles numéricos en Julia compiten con Fortran.

Arrays: la estructura central

Los arrays en Julia son multidimensionales por diseño. Un Array{Float64, 2} es una matriz, y Array{Float64, 1} es un vector. Las operaciones básicas de creación son directas:

# Crear arrays
v = zeros(5)           # vector de 5 ceros Float64
m = ones(3, 4)         # matriz 3x4 de unos
r = rand(100)          # 100 valores aleatorios uniformes en [0,1]
n = randn(50)          # 50 valores de distribución normal

# Rango con paso
x = range(0, 2*pi, length=100)  # 100 puntos de 0 a 2?
y = range(1, 10, step=0.5)      # de 1 a 10 con paso 0.5

# Array literal
A = [1 2 3; 4 5 6; 7 8 9]   # matriz 3x3

# Dimensiones e información
size(A)        # (3, 3)
length(A)      # 9
eltype(A)      # Int64
ndims(A)       # 2

La indexación en Julia empieza en 1. Puede parecer extraño para quien viene de Python, pero es consistente con Matlab y Fortran, los lenguajes de referencia en computación científica. La notación de rangos es A[2:4, :] para filas 2 a 4, todas las columnas.

Broadcasting

El operador punto (.) aplica cualquier función o operador elemento a elemento. Es uniforme para toda la librería, no solo para funciones de NumPy:

v = [1.0, 2.0, 3.0, 4.0, 5.0]

# Funciones matemáticas elemento a elemento
sin.(v)          # sin de cada elemento
log.(v)          # log de cada elemento
sqrt.(v)         # raíz cuadrada de cada elemento

# Operaciones aritméticas
2 .* v .+ 1      # 2x + 1 para cada x
v .^ 2           # cuadrado de cada elemento
v ./ sum(v)      # normalizar

# Broadcasting entre arrays de diferente forma
A = rand(3, 3)
b = rand(3)
A .+ b'          # suma broadcasting: b' se extiende por filas

Lo mejor del broadcasting con punto es que aplica a cualquier función, incluidas las que escribas tú. Si tienes una función f(x), f.(v) la aplica a cada elemento de v sin que tengas que hacer nada especial.

LinearAlgebra: BLAS y LAPACK nativos

La librería estándar LinearAlgebra proporciona acceso directo a las rutinas de álgebra lineal de alto rendimiento. OpenBLAS viene por defecto, y se puede cambiar a MKL de Intel para hardware específico.

using LinearAlgebra

A = rand(4, 4)
b = rand(4)

# Multiplicación de matrices (llama a DGEMM)
C = A * A'           # A por su transpuesta

# Factorizaciones
F_lu  = lu(A)        # factorización LU
F_qr  = qr(A)        # factorización QR
F_svd = svd(A)       # SVD: valores singulares
F_eig = eigen(A)     # valores y vectores propios

# Resolver sistema lineal Ax = b
x = A  b            # equivalente a solve(A, b), usa LU por defecto

# Normas
norm(b)              # norma L2
norm(A, Inf)         # norma infinito
opnorm(A)            # norma de operador

# Determinante, traza, rango
det(A)
tr(A)
rank(A)

El operador para resolver sistemas lineales es uno de los aciertos de diseño de Julia. Internamente elige el algoritmo más apropiado según la estructura de la matriz: LU general, Cholesky para matrices definidas positivas, o métodos especiales para matrices dispersas.

Matrices dispersas

Para matrices con muchos ceros, SparseArrays de la librería estándar es eficiente en memoria y en operaciones:

using SparseArrays

# Crear matriz dispersa
S = sprand(1000, 1000, 0.01)  # 1000x1000 con 1% de densidad

# Desde coordenadas
i = [1, 3, 5]; j = [2, 4, 6]; v = [1.0, 2.0, 3.0]
S2 = sparse(i, j, v, 6, 6)

# Las operaciones estándar funcionan igual
b = rand(1000)
x = S  b          # resolver sistema disperso

# Convertir
dense = Matrix(S)  # dispersa a densa (cuidado con matrices grandes)

Vistas sin copia

Una de las causas más frecuentes de código Julia más lento de lo esperado es crear copias innecesarias al hacer slicing. @view evita eso:

A = rand(1000, 1000)

# Sin @view: crea una copia de la submatriz
sub = A[1:500, 1:500]       # 500*500*8 bytes copiados

# Con @view: referencia a la memoria original, sin copia
sub_view = @view A[1:500, 1:500]

# Para múltiples vistas en una expresión
@views begin
    fila = A[3, :]
    col  = A[:, 7]
    bloque = A[1:10, 1:10]
end

Números de punto flotante y precisión

Julia tiene soporte nativo para diferentes precisiones:

# Tipos de punto flotante
x32 = Float32(3.14)    # 32 bits (más rápido en GPU)
x64 = Float64(3.14)    # 64 bits (defecto, máxima compatibilidad)
xbig = BigFloat(3.14)  # precisión arbitraria (más lento)

# Aritmética de intervalos (con IntervalArithmetic.jl)
# Propagación de errores de redondeo controlada

# Números complejos nativos
z = 3.0 + 4.0im
abs(z)       # módulo: 5.0
angle(z)     # argumento en radianes
conj(z)      # conjugado

Esta variedad de tipos numéricos, combinada con el multiple dispatch que ya vimos en el artículo sobre el sistema de tipos, permite escribir algoritmos numéricos genéricos que funcionan igual con Float32, Float64 o BigFloat. En el contexto de machine learning con Flux.jl, esto significa que el mismo código de entrenamiento puede correr en CPU con Float64 o en GPU con Float32 sin cambios.

Para quien viene de Python y NumPy, la diferencia más notable no está en la sintaxis sino en que estos bucles se ejecutan a velocidad nativa. No hay el problema del «bucle lento de Python» que obliga a vectorizar todo: en Julia un bucle explícito sobre millones de elementos es tan rápido como la versión vectorizada.

Imagen: Pexels / Pranjall Kumar

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP