**Computación Científica y Julia**
La Computación Científica es una disciplina fundamental en el desarrollo moderno de la ciencia, que se sitúa en la intersección entre las matemáticas aplicadas, la informática y los dominios científicos tradicionales como la física, la biología o la economía. Su objetivo es resolver problemas complejos del mundo real mediante la terna de formulación de modelos matemáticos, implementación computacional y análisis numérico de los resultados.
Desde finales del siglo XX, la computación científica ha transformado profundamente el proceso de descubrimiento científico. Experimentos que antes requerían años de trabajo en laboratorios físicos hoy pueden simularse en cuestión de horas gracias al poder de cálculo de los ordenadores. Esta capacidad ha permitido explorar sistemas complejos —desde la dinámica de galaxias hasta la propagación de epidemias— que serían inaccesibles mediante métodos analíticos o experimentales tradicionales. Esta transformación está siendo acelerada por los nuevos modelos de Inteligencia Artificial (IA) y Aprendizaje Automático (ML), que están revolucionando la forma en que se analizan y comprenden los datos científicos. La IA y el ML permiten a los investigadores descubrir patrones ocultos, optimizar procesos y predecir comportamientos en sistemas complejos, lo que amplía aún más las fronteras del conocimiento científico.
Paralelamente, el desarrollo de lenguajes de programación ha sido crucial en este avance, al proporcionar herramientas cada vez más expresivas, eficientes y accesibles para científicos e ingenieros. En este contexto, Julia ha emergido como una solución innovadora diseñada específicamente para la computación científica. Julia combina la facilidad de uso de lenguajes como Python o MATLAB con el rendimiento de lenguajes compilados como C o Fortran. Esta combinación permite a los investigadores escribir código de alto nivel sin sacrificar la velocidad, facilitando el trabajo colaborativo, el prototipado rápido y la ejecución eficiente de algoritmos numéricamente intensivos.
Además, Julia ofrece características avanzadas como la tipificación múltiple, paralelismo nativo y un rico y vivo ecosistema de bibliotecas científicas. Estas cualidades han llevado a su adopción en áreas tan diversas como la simulación de sistemas físicos, el aprendizaje automático, la optimización y la estadística bayesiana. Sin embargo, su velocidad de adopción no es tan rápida como la de otros lenguajes, como Python, que ha dominado el campo de la ciencia de datos y el aprendizaje automático. Esto se debe en parte a la inercia de la comunidad científica y a la amplia disponibilidad de bibliotecas y recursos en Python.
# Sobre la Computación Científica (SC)
La **Computación Científica** es un campo multidisciplinario en rápido crecimiento que utiliza capacidades informáticas avanzadas para comprender y resolver problemas complejos. Es un área de la ciencia que abarca muchas disciplinas, pero en esencia implica el desarrollo de modelos y simulaciones para comprender los sistemas naturales. Podemos indicar las siguientes áreas principales de interés:
* Algoritmos (numéricos y no numéricos), modelado y simulación matemáticos y computacionales desarrollados para resolver problemas científicos (por ejemplo, biológicos, físicos y sociales), de ingeniería y de humanidades.
* Ciencias de la computación y de la información que desarrollan y optimizan los componentes avanzados de hardware, software, redes y gestión de datos del sistema necesarios para resolver problemas computacionalmente exigentes.
* La infraestructura informática que sustenta tanto la resolución de problemas científicos y de ingeniería como el desarrollo de las ciencias de la información y la computación.

En la práctica, se trata de la aplicación de técnicas como el modelado y la simulación por ordenador (y otras formas de computación derivadas del análisis numérico y la informática teórica) para resolver problemas en diversas disciplinas científicas con el fin de ampliar su comprensión, principalmente mediante el análisis de modelos matemáticos implementados en computadoras. En algunos casos, estos modelos requieren cantidades masivas de cálculos (generalmente de punto flotante) y suelen ejecutarse en supercomputadoras o plataformas de computación distribuida.
En este contexto, el término _científico computacional_ se utiliza para describir a una persona experta en computación científica... suele ser un científico, ingeniero o matemático que aplica la computación de alto rendimiento de diversas maneras para impulsar el estado del arte en sus respectivas disciplinas aplicadas. Un científico computacional debe ser capaz de:
* Reconocer problemas complejos.
* Conceptualizar adecuadamente el sistema que contiene estos problemas.
* Diseñar un marco de algoritmos adecuados para estudiar este sistema: la simulación.
* Elegir una infraestructura informática adecuada (computación paralela/computación en cuadrícula/supercomputadoras).
* Evaluar en qué nivel el resultado de la simulación se asemeja al sistema real: validación del modelo.
* Ajustar la conceptualización del sistema en consecuencia.
* Repetir el ciclo hasta obtener un nivel adecuado de validación: el científico computacional confía en que la simulación genere resultados adecuadamente realistas para el sistema, en las condiciones estudiadas.
La Computación Científica se considera actualmente una tercera columna de soporte para la ciencia, que complementa la _experimentación/observación_ y la _teoría_.

De hecho, un esfuerzo considerable en _Ciencias de la Computación_ se ha dedicado al desarrollo de algoritmos, la implementación eficiente en lenguajes de programación y la validación de resultados computacionales.
Es interesante apuntar que desde el punto de vista de la _Filosofía de las Ciencias_ se ha abordado la cuestión de hasta qué punto la SC puede considerarse Ciencia, abordando la cuestión general de la epistemología: cómo obtenemos información desde la SC. En cualquier caso, parece que las limitaciones que se encuentran son las habituales en la relación entre el conocimiento que se puede extraer del sistema original y de la simulación. Pero este problema se ve mitigado en cierta forma con la SC, ya que, a diferencia de otras aproximaciones, en ella se usan modelos matemáticos que representan la teoría subyacente de forma ejecutable.
## SC y Ciencia de Datos
Como hemos comentado, la SC estudia/desarrolla la aplicación de simulaciones por computadora, generalmente basadas en una combinación de modelos matemáticos y métodos numéricos, para resolver problemas en Ciencia e Ingeniería. Por otra parte, la Ciencia de Datos (DS) se refiere al análisis estadístico y la interpretación de datos resultantes de mediciones o simulaciones experimentales.
El aumento continuo en la potencia computacional disponible ha hecho posible simulaciones científicas a escala cada vez mayor que producen cantidades cada vez mayores de datos. En algunos casos, el procesamiento y la interpretación de estos datos se han convertido en un cuello de botella que limita la utilidad de las simulaciones. Al mismo tiempo, la creciente capacidad para registrar y almacenar flujos de datos en muchos entornos, por ejemplo, estaciones de monitoreo meteorológico, entornos minoristas, tráfico web en línea, etc., ha impulsado la necesidad de herramientas eficientes de DS. Estos desarrollos han creado nuevas oportunidades en el nexo de los ámbitos tradicionalmente separados de la SC y DS.
En cualquier caso, podemos dar el siguiente resumen de diferencias:
| Aspecto | Computación Científica | Ciencia de Datos |
|---------|------------------------|------------------|
| Enfoque | Modelado y simulación de sistemas físicos | Extracción de información a partir de datos |
| Técnicas Matemáticas | Métodos numéricos, EDO/EDP | Estadística, probabilidad, ML |
| Técnicas Informáticas | HPC, desarrollo de algoritmos | Manipulación de datos, ML, big data |
| Aplicaciones | Ingeniería, Física, Química,... | Analítica empresarial, ciencias sociales, salud |
Aunque ambos campos se basan en técnicas matemáticas y computacionales, sus objetivos y metodologías difieren considerablemente. SC se ocupa principalmente de resolver problemas científicos complejos mediante la simulación y el modelado, mientras que DS se centra en el análisis y la interpretación de datos para obtener información útil.
## SciML: SC + ML
El **Aprendizaje Automático Científico** (SciML) es un componente central de la Inteligencia Artificial (IA) y una tecnología computacional que puede entrenarse con datos científicos para aumentar o automatizar las habilidades humanas y transformar la investigación científica. La naturaleza transversal del ML y la IA proporciona un fuerte incentivo para formular una agenda de investigación priorizada que maximice las capacidades y los beneficios científicos.
Se debe tener presente que SciML no es ML estándar sino la combinación de técnicas de SC con ML. Por lo tanto, el objetivo no es diseñar bibliotecas de ML, sino el desarrollo de herramientas de SC que funcionen a la perfección junto con los flujos de trabajo de ML de próxima generación. Esto incluye:
* Herramientas de alto rendimiento y precisión para modelado y simulación de SC estándar.
* Compatibilidad con programación diferenciable y diferenciación automática.
* Herramientas para construir modelos complejos multiescala.
* Métodos para manejar problemas inversos, calibración de modelos, controles y análisis bayesiano.
* Herramientas de modelado simbólico para generar código eficiente para solucionadores de ecuaciones numéricas.
* Métodos para el descubrimiento automático de ecuaciones (bio)físicas.
La comunidad de SciML ha identificado seis direcciones prioritarias de investigación (PRD) que son fundamentales para el desarrollo de métodos SciML. Estas PRD se centran en la integración de la ciencia computacional y el aprendizaje automático, y abordan tanto los desafíos fundamentales como las capacidades necesarias para avanzar en el campo.
Los tres primeros PRD describen temas de investigación fundamentales que son comunes al desarrollo de todos los métodos SciML y corresponden a la necesidad de conocimiento del dominio, interpretabilidad y robustez. Los otros tres PRD describen temas de investigación sobre capacidades y corresponden a los tres principales casos de uso para el análisis masivo de datos científicos, el modelado simulación mejorados por ML, la automatización inteligente y el apoyo a la toma de decisiones para sistemas complejos:
1. **SciML consciente del dominio**. Es poco probable que los métodos de SciML lleguen a sustituir a los modelos establecidos basados en mecanismos físicos y conocimientos científicos; sin embargo, existe una importante oportunidad para que SciML complemente los modelos tradicionales. El conocimiento del dominio incluye principios físicos, simetrías, restricciones, opiniones de expertos, simulaciones computacionales, incertidumbres, etc. Por tanto, el objetivo de esta dirección es integrar dicho conocimiento con los métodos SciML, mejorando la precisión, interpretabilidad y defendibilidad de los modelos SciML, reduciendo al mismo tiempo los requisitos de datos y acelerando el entrenamiento de los modelos SciML. Para la consecución de estos objetivos se requieren nuevos métodos matemáticos para aprender características mejoradas del modelo que estén limitadas por el conocimiento del dominio, incluida la fusión de fuentes de datos multimodales y heterogéneas para extraer características.
2. **SciML interpretable**. Tradicionalmente, la comprensión física ha sido la base del modelado. La confianza de un usuario en las predicciones de un modelo está directamente vinculada a la convicción de que el modelo tiene en cuenta su conocimiento del dominio (por ejemplo, las variables, los parámetros y las leyes físicas correctas). En general, existe una tensión entre la necesidad de aumentar la complejidad de los modelos de ML para mejorar los resultados y la necesidad de que los usuarios interpreten los modelos y extraigan nuevas ideas y conclusiones. Este reto es ampliamente reconocido. Sin embargo, las aplicaciones SciML presentan retos y oportunidades únicos para utilizar el conocimiento del dominio existente con el fin de aumentar la interpretabilidad de los modelos ML. El progreso en esta dirección requiere el desarrollo de nuevos enfoques de exploración y visualización para interpretar modelos complejos utilizando el conocimiento del dominio, así como nuevas métricas para cuantificar las diferencias entre modelos.
3. **SciML robusto**. Para ocupar su lugar como metodología científica y ser aceptados para su uso común en ciencias y aplicaciones de alto riesgo, los métodos SciML deben ser robustos y fiables. Aunque los métodos de ML son muy utilizados, la integración de protocolos de verificación, validación y reproducibilidad están en pañales. La credibilidad de la investigación basada en SciML requiere que los resultados procedan de un proceso que no sea sensible a las perturbaciones en los datos de entrenamiento, la elección del modelo y/o los errores computacionales. El progreso en esta dirección requiere investigación para demostrar que los métodos e implementaciones de SciML están bien planteados y son estables y robustos.
4. **SciML intensivo en datos**. SciML en modelos y datos complejos a gran escala se enfrenta a una serie de retos, incluyendo datos de entrada de alta dimensión, ruidosos e inciertos, así como información limitada sobre la validez del modelo. La incorporación de la estadística, la cuantificación de la incertidumbre y la modelización probabilística en SciML proporciona un marco para gestionar algunos de estos retos. En particular, estos enfoques pueden abordar el mal condicionamiento, la no unicidad y el sobreajuste, y permitir la cuantificación de la incertidumbre requerida en las predicciones ML. Además, los métodos estadísticos y probabilísticos pueden ayudar a descubrir la estructura de los datos para mejorar la comprensión científica. Al mismo tiempo, la aplicación de estos métodos en SciML se ve dificultada por el gran volumen y complejidad de los datos, así como por la estructura altamente dimensional de los modelos probabilísticos SciML. El progreso en esta dirección requiere el desarrollo de métodos mejorados para el aprendizaje estadístico en sistemas SciML de alta dimensión con datos ruidosos y complejos, para identificar la estructura en datos complejos de alta dimensión, y para el muestreo eficiente en espacios paramétricos y de modelos de alta dimensión.
5. **Modelización y simulación mejoradas por ML**. Los códigos de simulación que modelan fenómenos físicos complejos, a menudo presentan variaciones drásticas de escala y comportamiento incluso dentro de una misma simulación. Para obtener rendimiento, robustez y fidelidad, la experiencia humana suele ser integral en el proceso de simulación para obtener soluciones de calidad. La tendencia creciente es que los modelos, discretizaciones y solucionadores numéricos en el corazón de los códigos de aplicación sean más adaptables, normalmente mediante el uso de controles teóricos simples y/o heurísticos. La utilización juiciosa de los algoritmos SciML (lo que se denominan en algunos ámbitos como modelos surrogados) para adaptar mejor los aspectos de los modelos numéricos y sus interacciones con el hardware informático, cada vez más complejo, puede reportar enormes beneficios. Del mismo modo, los algoritmos numéricos tradicionales están en el núcleo de los algoritmos SciML, por lo que SciML puede hacerse más eficiente, robusto y escalable aprovechando los amplios conocimientos de la comunidad de SC. Catalizar la interacción de la SC y los algoritmos de ML tiene el potencial de mejorar el rendimiento de ambos, pero se requiere el desarrollo de nuevos métodos para cuantificar las compensaciones y gestionar de forma óptima la interacción entre los modelos e implementaciones tradicionales y de ML.
6. **Automatización inteligente y apoyo a la toma de decisiones**. Las aplicaciones que iteran en torno a una simulación directa (por ejemplo, en optimización, cuantificación de la incertidumbre, problemas inversos, asimilación de datos y control) constituyen un objetivo importante para muchas de las capacidades de simulación y modelización, en muchos casos en apoyo de decisiones. En la frontera de las decisiones basadas en la simulación en Ciencia e Ingeniería se plantean varios retos importantes: cómo hacer manejable la tarea de evaluar un modelo de simulación complejo y costoso en un espacio de parámetros de alta dimensión; cómo combinar mejor los datos experimentales y de simulación para fundamentar las decisiones; cómo validar las evaluaciones resultantes y traducir su incertidumbre en confianza cuantificable para un responsable de la toma de decisiones; y cómo gestionar la interacción entre la automatización y la toma de decisiones humana. Estos retos son especialmente acuciantes para SciML. Además de los avances en las anteriores direcciones, también se requieren nuevos métodos matemática y científicamente justificados para guiar la adquisición de datos y garantizar su calidad y adecuación, métodos SciML mejorados para los datos multimodales que se encuentran en las aplicaciones científicas, y nuevos métodos para cuantificar las compensaciones y gestionar de forma óptima los recursos utilizados en el apoyo a la toma de decisiones y las tareas relacionadas.
Estas seis direcciones prioritarias proporcionan una base sólida para aplicar una estrategia de investigación y desarrollo coherente y a largo plazo. Afortunadamente, durante las 2 últimas décadas, las matemáticas y la SC han marcado algunas directrices para el tipo de investigación básica que puede sostener los avances necesarios.
# Julia para Computación Científica
La combinación de velocidad, facilidad de uso y capacidades numéricas avanzadas de Julia lo convierte en una opción destacada para la computación numérica y científica. Ya sea para manipular matrices, resolver ecuaciones diferenciales o realizar simulaciones a gran escala, Julia proporciona las herramientas y la eficiencia necesarias para abordar problemas complejos. Su creciente ecosistema y su activa comunidad garantizan la innovación continua y el soporte para aplicaciones de vanguardia.
Históricamente, Python ha sido el lenguaje dominante para la investigación en ML, incluso en el ámbito científico. Python es intuitivo y cuenta con un rico ecosistema en todas las ciencias naturales. Sin embargo, su auge como lenguaje de referencia para el ML ha sido, en muchos sentidos, antinatural. Python es un lenguaje de _scripting_, extremadamente lento y difícil de mantener. La llegada de Julia se diseñó, en muchos sentidos, para abordar estas limitaciones de Python. Julia cuenta con un conjunto de características a nivel de lenguaje diseñadas para cálculos numéricos intuitivos y rápidos que superan con creces las capacidades de Python.
Desde la introducción de Julia en 2012, el lenguaje ha experimentado un crecimiento constante entre los profesionales. Dada la intención específica de Julia de resolver problemas en SciML, es natural preguntarse por qué Julia no ha desafiado a Python en popularidad dentro de las ciencias naturales. Parte de esto se puede atribuir al impulso que Python ha acumulado a lo largo de los años dentro de la comunidad. Sin embargo, con el rápido crecimiento del SciML en los últimos años, está por verse si Julia experimentará una adopción más amplia.
En cualquier caso, resulta interesante explorar la idoneidad de Julia como herramienta clave para SciML. Para ello, es importante centrarse no solo en la amplia gama de bibliotecas disponibles, sino también en su rendimiento, filosofía de diseño y ergonomía general. Un tema clave es que los diferentes lenguajes de programación proporcionan diferentes abstracciones que cambian significativamente la forma en que un usuario interactúa con un problema de SciML, y Julia ofrece un conjunto de abstracciones muy diferente al de otros ecosistemas.
A pesar de todo esto, y aunque el ecosistema de Julia ofrece multitud de abstracciones útiles para este tipo de problemas, parece que presenta algunas limitaciones que son lo suficientemente graves como para impedir su adopción generalizada. Intentaremos describirlas a lo largo del texto.
## Áreas y características
Hay multitud de proyectos que se desarrollan en Julia y que, haciendo uso de sus capacidades en cuanto a computación numérica han demostrado su utilidad en el ámbito de la SC. Algunos áreas fundamentales en las que podemos encontrar usos de Julia son:
1. **Investigación científica**: ampliamente en física computacional, biología, bioinformática, astronomía y química.
2. **DS y ML**: la integración del lenguaje con los marcos de ML y AI lo convierte en una opción ideal para investigación y desarrollo.
3. **Finanzas**: Julia se utiliza para finanzas cuantitativas, modelado de riesgos y comercio algorítmico.
4. **Problemas de optimización**: simplifica el modelado y la resolución de problemas de optimización.
5. **Simulación**: simulaciones de Monte Carlo, métodos de elementos finitos y simulaciones de sistemas dinámicos.
Todos estos proyectos tienen en común un uso intensivo de computación numérica, lo que hace que Julia sea una elección ideal. A continuación se presentan algunas características clave de Julia que la hacen destacar en estos ámbitos:
* **Alto rendimiento**: El rendimiento de Julia es comparable al de lenguajes de tipado estático como C y Fortran. Esta velocidad se logra mediante varias decisiones de diseño clave, como la _compilación Just-In-Time_ (JIT) por medio de LLVM (máquina virtual de bajo nivel) para compilar el código en código de máquina nativo eficiente justo antes de la ejecución; una _inferencia de tipos_ agresiva para optimizar la ejecución; y una _gestión eficiente de la memoria_, garantizando una sobrecarga mínima de recolección de basura en tiempo de ejcución.
* **Tipado dinámico** con anotaciones de tipo robustas que mejoran la claridad del código y permiten al compilador generar código máquina optimizado.
* **Bibliotecas numéricas** que incluyen funcionalidades de Álgebra lineal (soporte nativo para operaciones con vectores, matrices y tensores, incluidas factorizaciones de matrices, autovalores y descomposición en valores singulares), Generación de números aleatorios (con capacidades potentes y extensibles que hacen que Julia sea ideal para simulaciones) y Funciones especiales (implementaciones eficientes de funciones matemáticas como funciones de Bessel, Gamma, etc.).
* **Despacho Múltiple**, que permite especializar las funciones según el tipo y el número de sus argumentos, lo que resulta especialmente útil en computación numérica.
* **Paralelismo nativo**, que incluye _multithreading_ (para paralelismo de memoria compartida), _computación distribuida_ (para cálculos a gran escala en múltiples máquinas) y compatibilidad con GPU (no solo CUDA).
* **Sintaxis de alto nivel**, que de forma concisa y expresiva facilita la traducción directa de fórmulas matemáticas a código (usando Unicode de forma extensiva).
* **Interoperabilidad**, que le permite integrarse con otros lenguajes como Python, R y C/C++.
* **Análisis de datos**, con una amplia gama de paquetes para la manipulación y visualización de datos. Como `DataFrames.jl` (similar a `Pandas` de Python), `Plots.jl` (una biblioteca de visualización versátil que se integra con múltiples backends, incluidos `GR`, `PyPlot` y `Plotly`).
* **Metaprogramación**, lo que permite a los usuarios generar y manipular código dinámicamente. Esto puede ser especialmente ventajoso en computación numérica para automatizar tareas repetitivas.
* **Ecosistema de código abierto y extensible**, con un dinámico ecosistema de paquetes que está creciendo rápidamente. Algunos paquetes destacados para el procesamiento numérico incluyen: `DifferentialEquations.jl` (EDOs y EDPs), `JuMP.jl` (problemas de optimización), `Flux.jl` (aprendizaje profundo), `Distributions.jl` (distribuciones estadísticas), etc.
## Comparativa de ecosistemas
Teniendo en cuenta que su uso depende, en mayor o menor medida, de la elección que se haga frente a otros lenguajes para la resolución de problemas habituales en SC, vamos a comparar los ecosistemas de Julia con sus contemporáneos, principalmente con Python, ya que es la opción preferida por la mayoría de los investigadores que trabajan con SC (lenguajes como C, C++, Fortran y Cuda tienen un uso similar a menor escala).
### Álgebra lineal
Podría decirse que la parte más importante del ecosistema de Julia es la biblioteca estándar para álgebra lineal. Los cálculos numéricos rápidos motivaron la creación de Julia. Para lograrlo, Julia emplea un compilador Just-in-Time (JIT) para acelerar su velocidad de cálculo y cuya utilidad se puede observa en todos los usos del lenguaje, pero especialmente en su biblioteca de álgebra lineal. Por ejemplo, Julia es significativamente más rápida que Python y casi tan rápida como C en términos de multiplicación de matrices aleatorias y estadísticas de matrices aleatorias. Aunque las operaciones matriciales de Python se pueden compilar JIT con Jax, la mejora exacta del rendimiento en comparación con C o Julia aún no se ha evaluado adecuadamente. Julia también cuenta con una gran cantidad de funciones fáciles de usar para la factorización de matrices y bibliotecas para trabajar con grafos dispersos que pueden agilizar aún más las operaciones con matrices.

### Optimización restringida
De forma similar al álgebra lineal numérica, los algoritmos de optimización restringida se integran bien con el resto de fortalezas de Julia. Estos algoritmos son rápidos y matemáticamente sólidos, y en comparación con Python, mucho más completos.
Julia ofrece el soporte más sofisticado para especificar problemas de optimización en variedades. Normalmente, los enfoques ingenuos para resolver problemas de SC no tienen en cuenta los sesgos inductivos ni las restricciones físicas. Un enfoque para incorporar restricciones físicas a un problema es especificar una variedad en la que residen los datos. Esto se implementa en Julia con `Manopt` que, aunque tiene versiones para Python y Matlab, éstas ofrecen solo un subconjunto estricto de lo que ofrece en Julia. La adaptación de Python, en particular, es muy limitada, y los paquetes similares en R y C++ no se mantienen de forma activa.
`Manopt` está diseñado para restringir las actualizaciones iterativas y mantenerlas en una variedad específica. Sin embargo, a menudo las restricciones se especifican de forma flexible mediante funciones de pérdida, como en el caso de las _Redes Neuronales Basadas en Física_ (PINN), que pueden considerar las condiciones iniciales y de contorno al resolver una ley física especificada como una ecuación diferencial. Julia gestiona este tipo de particularidades de forma excelente con `Flux.jl` y `Lux.jl`, que proporcionan backends de redes neuronales compatibles con los paquetes `DiffEqFlux.jl` y `NeuralPDE.jl`. Incluso más allá de los métodos neuronales, Julia cuenta con un ecosistema extremadamente rico para resolver ecuaciones diferenciales. `Lux.jl`, por ejemplo, admite tipos arbitrarios, lo que lo hace compatible con solucionadores de EDO/EDP arbitrarios e incluso con otros lenguajes. Por el contrario, no existen soluciones de facto para el aprendizaje automático basado en la física en Python. Es decir, ninguna solución existente se adapta a las redes neuronales especificadas en TensorFlow, Haiku, o PyTorch simultáneamente. Como resultado, observamos arquitecturas estándar distribuidas en diferentes subecosistemas sin bibliotecas disponibles para conectarlas. Por ejemplo, observamos las implementaciones canónicas para _redes neuronales lagrangianas_ y de normalización de flujos implementado en Jax, y las implementaciones canónicas de _Kolmogorov Arnold Networks_ (KAN) y redes de operadores profundos implementado en PyTorch.
Julia completa su conjunto de bibliotecas de optimización restringida con `GeometricFlux.jl`, que se basa nuevamente en el marco `Flux.jl`, y `GraphNeuralNetworks.jl`, con capas y funcionalidades específicas para los objetos de _Geometric DL_, que incluyen grafos, conjuntos, mallas, espacios euclidianos, grupos y espacios homogéneos, geodésicas y variedades, y todas las funcionalidades de Geometría Algebráica. Al igual que con los PINN, estas mismas funcionalidades aparecen dispersas en diferentes bibliotecas de Jax y Torch, que a menudo son incompatibles entre sí. Esta compatibilidad convierte a Julia en una opción muy atractiva para trabajar con problemas de SciML.
### Diferenciación automática
Julia mantiene varios paquetes diferentes para la _diferenciación automática_ (AD) tanto en _modo directo_ como en _modo inverso_ con `ForwardDiff.jl` y `Zygote.jl`, respectivamente. En otros ecosistemas, los diversos modos de AD se manejan normalmente dentro de la misma biblioteca, como es el caso de PyTorch y Jax. En general, las bibliotecas de AD de Julia tienen un alcance mucho más ambicioso, lo que permite una mayor sobrecarga del usuario a costa de la posible facilidad de uso.
Debido a esta sobrecarga, se suele identificar esta como una de las pocas áreas donde existe mucha fricción al escribir código de Julia. Una forma de lograrlo es mediante intervenciones y definiciones de gradientes personalizadas. En Julia, las macros para anular y especificar reglas de diferenciación se encuentran en varias bibliotecas independientes, incluyendo el propio `Zygote.jl` y la biblioteca de propósito general `DiffRules.jl`. Navegar por la complejidad de las diferentes herramientas suele ser contraintuitivo y requiere un conocimiento del dominio mucho mayor que con otras herramientas. En cambio, las reglas de diferenciación personalizadas son muy fáciles en Jax con las macros `@custom_JVP` y `@custom_VJP`.
`Zygote.jl` se utiliza principalmente como backend de la biblioteca de aprendizaje automático `Flux.jl`. Además, las herramientas de diferenciación automática mencionadas anteriormente también permiten usar `Optim.jl` con `SymbolicRegressions.jl`, donde Julia ofrece una clara ventaja en términos de soporte. De hecho, la alternativa de Python, `PySR`, es simplemente un contenedor para esta biblioteca, que a su vez es una dependencia de PyTorch.
### Programación probabilística
Julia cuenta con numerosos lenguajes de _programación probabilística_ (PPL) que hacen que los problemas con estadística bayesiana sean extremadamente fáciles de expresar e incluyen paquetes como `SOSS.jl` y `Turing.jl`. SOSS.jl proporciona un conjunto único de herramientas para trabajar con objetos de la _Teoría de la Medida_ que no se encuentras en otros lenguajes. Salvo por el soporte teórico, existe una biblioteca similar en Python con `PyAutoFit`, y en Jax `NumPyro` hace que sea excepcionalmente fácil especificar problemas probabilísticos.
## Filosofía de diseño
Julia se fundó sobre claros ideales de diseño que han conformado su estructura interna y sus fortalezas. Algunos de estas directrices son:
### Despacho múltiple
Julia hace un uso extensivo del _Despacho Múltiple_, un método para seleccionar automáticamente el comportamiento de una función según los tipos de entrada y que, en la práctica, puede hacer que la escritura de código sea mucho más ergonómica para el usuario final. Por ejemplo, la biblioteca de Python/C++ para calcular funciones de correlación, `TreeCorr`, tiene 18 diferentes subclases que el usuario debe recordar para correlacionar distintos tipos de objetos (por ejemplo, `KVCorrelation` para correlaciones escalar-vector y `VVCorrelation` para correlaciones vector-vector). Obviamente, puede ser difícil recordarlas todas. Con el despacho múltiple de Julia podemos definir con seguridad la misma función 18 veces para los diferentes tipos de entradas sin tener que preocuparnos por especificar cuál llamar en la práctica. Bibliotecas como `JuMP.jl`, `ForwardDiff.jl` y la biblioteca estándar de Julia hacen un uso extensivo del despacho múltiple.
Los siguiente contenidos desplegables profundizan un poco en cómo funciona el despacho múltiple, ya que es una característica distintiva de Julia frente al uso de ls otras alternativas más usuales.
!!!note:Dos vías tradicionales
Los elementos fundamentales con los que se construye una solución en un lenguaje de programación son _funciones/programas_, _tipos de datos_ y _bibliotecas/módulos/paquetes_. Brevemente:
* Las funciones o programas son procedimientos para tomar alguna entrada, hacer algo con ella y producir alguna salida.
* Los tipos de datos son colecciones de unidades de información (atómica o estructurada) sobre los que operan las funciones.
* Las bibliotecas, paquetes y módulos son colecciones de funciones, junto con descripciones de los tipos de datos con los que trabajan, agrupadas para realizar un conjunto de tareas relacionadas. Por ejemplo, un conjunto de funciones para dibujar gráficos, donde las funciones individuales podrían ser para dibujar diferentes tipos de gráficos (circulares e histogramas) y el tipo de dato para un gráfico circular sería, por ejemplo, una lista de pares de elementos, donde el primero es una palabra o frase y el segundo un porcentaje.
Ahora que está de moda el mundo de la cocina, podemos dar una analogía muy intuitiva de esta misma división en este ámbito:
* La biblioteca o el paquete se convierte en el recetario (un libro centrado en la preparación de postres o sopas, por ejemplo).
* Las funciones o programas pueden considerarse recetas completas para preparar un plato o técnicas o procedimientos, como saltear. Podemos visualizarlos como engranajes, ya que son la maquinaria para procesar las materias primas.
* Los tipos de datos son las materias primas con las que construir los platos.
La diferencia entre _lenguajes de la cocina_ vendrá dado por cómo podemos organizar nuestro recetario.
Imaginemos que nuestro recetario está organizado de tal manera que las recetas solo funcionan con ciertos ingredientes. Por ejemplo, podemos buscar _cómo saltear_ y encontrar el procedimiento para saltear cebollas o camarones. Todos estos procedimientos son diferentes, ya que utilizan ingredientes distintos. Si las recetas funcionan como un lenguaje de programación, las listas de ingredientes forman parte de ellas y, de hecho, están incluidas en ellas.
Si queremos actualizar nuestro recetario añadiendo una nueva receta o describiendo una nueva técnica (por ejemplo, cosas que se pueden hacer con una licuadora), es sencillo añadir las nuevas recetas. Simplemente escribe una nueva que describa los ingredientes de interés, incluyendo aquellos que puedan formar parte de otras recetas existentes.
No será necesario revisar ninguna de las recetas existentes: _agregar nuevas recetas no significa que las antiguas queden obsoletas_.
¿Y si queremos incorporar un ingrediente nuevo? Sin duda, podemos crear recetas completamente nuevas con él. Pero si ninguna de las recetas anteriores del libro menciona pescado, tendremos que revisarlas si queremos incorporarlo. Nos veremos obligados a modificar el trabajo que ya hemos realizado.
Sin embargo, hay más de una manera de organizar un recetario. ¿Qué pasaría si se organizara en torno a los ingredientes, en lugar de a los métodos de cocción? Para cada ingrediente, habría un conjunto de técnicas o métodos que lo acompañan. Aquí los métodos no existen por sí solos, sino que están vinculados a los ingredientes con los que se utilizan. Ahora es fácil añadir un nuevo ingrediente.
 Si la versión revisada de nuestro recetario necesita una receta de pescado, simplemente escribimos los procedimientos para su manipulación y agrupamos esa información con el pescado en un paquete ordenado.
Podemos añadir ingredientes al libro sin tener que modificar ninguna receta existente, que siguen siendo perfectamente utilizables. ¿Pero qué pasa si queremos añadir una nueva técnica al libro? ¿Y si la versión revisada pretende mostrarte lo que puedes hacer con la licuadora?
No podemos añadir nuevas técnicas sin modificar el trabajo ya realizado, ya que los métodos ahora están empaquetados dentro de los ingredientes. Para añadir un método novedoso, necesitamos modificar nuestros paquetes previamente ordenados y modificar los métodos ya escritos dependiendo del ingrediente al que se va a aplicar.
Estas dos formas de organizar un recetario son análogas a dos tipos de lenguajes de programación:
* Los recetarios organizados en torno a procedimientos están escritos en un _lenguaje funcional_.
* Los organizados en torno a ingredientes están escritos en un _lenguaje orientado a objetos_.
En un caso, no se pueden añadir nuevos ingredientes sin reescribir los procedimientos existentes, mientras que en otro caso no se pueden añadir nuevos procedimientos sin reescribir el trabajo existente.
Para repasar la terminología del diseño de lenguajes de programación:
* En un lenguaje funcional, se pueden añadir nuevas funciones sin modificar las existentes, pero añadir nuevos tipos de datos implica reescribir las existentes.
* En un lenguaje orientado a objetos, se pueden añadir nuevos tipos de datos a voluntad, pero si se quieren añadir nuevas funciones es necesario rediseñar los objetos existentes.
El problema es que, al usar cualquiera de estos paradigmas tradicionales, existen obstáculos para extender un paquete (libro de recetas) a nuevos dominios, y aún más obstáculos para combinar paquetes existentes de nuevas maneras. Que un paquete pueda extenderse sin modificar lo ya existente es crucial a la hora de reutilizar y combinar código existente, que que permite a los autores de bibliotecas escribir código de forma genérica, sin tener que mantener versiones especiales para cada aplicación, y permite a los usuarios finales extender módulos existentes sin introducir los errores que inevitablemente aparecen al realizar modificaciones y sin tener que mantener versiones privadas de cada biblioteca.
Obviamente, si hubiera una manera de organizar el recetario de forma que pudiera ampliarse libremente para incluir nuevos ingredientes y métodos, añadiendo contenido al libro sin modificar lo ya escrito, sería una gran ventaja. En lugar de organizar el libro estrictamente en torno a métodos o ingredientes, quizá exista una forma más general y flexible.
Este diagrama pretende sugerir la libre asociación de métodos con ingredientes, sin subordinar uno al otro. Los engranajes repetidos en diferentes colores sugieren que, en lugar de una colección aleatoria de funciones no relacionadas, normalmente crearíamos variaciones de funciones existentes para operar con diferentes conjuntos de ingredientes.
Por ejemplo, nuestro recetario hipotético y ampliable podría contener un procedimiento para freír pollo. Si queremos ampliarlo añadiendo un procedimiento para freír pescado, no necesitamos empezar desde cero: podemos escribir nuestro procedimiento para freír pescado indicando al lector que proceda igual que con el pollo, pero a una temperatura ligeramente superior y que retire el ingrediente después de 10 minutos, en lugar de 30.
Otra forma de comprender las tres formas de organizar un recetario en este experimento mental es imaginar cómo sería el índice en cada caso. Un extracto del contenido de la versión _funcional_ podría ser algo como:
```none
Capítulo 1: Freír
Pollo
Pez
Capítulo 2: Ebullición
Pollo
Remolachas
```
Podemos agregar un nuevo método simplemente agregando un nuevo capítulo, mientras que agregar un nuevo ingrediente significa editar los capítulos existentes.
El índice de la versión _orientada a objetos_ podría ser algo como:
```none
Capítulo 1: Pollo
Fritura
Hirviendo
Capítulo 2: Remolachas
Hirviendo
Salteado
```
Ahora podemos agregar un nuevo ingrediente agregando un nuevo capítulo, pero agregar un nuevo método significa editar capítulos ya terminados.
El índice de nuestro tercer enfoque sería:
```none
Capítulo 1: Pollo frito
Capítulo 2: Pollo hervido
Capítulo 3: Hervir la remolacha
Capítulo 4: Salteado de remolacha
```
Es evidente que cualquier procedimiento aplicado a cualquier ingrediente o conjunto de ingredientes puede añadirse como un nuevo capítulo, sin necesidad de modificar los capítulos existentes. Se pueden añadir libremente nuevos métodos e ingredientes.
Esta versión final podría parecer desorganizada en comparación con las otras dos, pero en una biblioteca de software real las relaciones entre los métodos y los ingredientes formarían parte de la estructura de la biblioteca. En nuestra metáfora del libro de cocina, el pollo y el pescado serían subconjuntos de la carne, las fresas y las cerezas podrían clasificarse como subconjuntos de los frutos rojos, freír y saltear podrían considerarse variaciones de un método más general, y así sucesivamente.
Esta tercera vía de organización es una metáfora de lo que, en diseño de lenguajes, se denomina _despacho múltiple_, un término que simplemente se refiere a la selección automática de un método en función de los tipos de datos a los que se aplica.
El despacho múltiple es la solución de Julia al problema de la organización. Es central al lenguaje, que, por lo tanto, no es ni orientado a objetos ni funcional, sino algo más potente y general que ambas aproximaciones. Es la fórmula mágica que le da a Julia el poder de simplificar lo que resulta tan difícil en la mayoría de los demás lenguajes: la combinación libre y directa de bibliotecas para realizar tareas que no fueron imaginadas por quienes las escribieron.
Vamos a crear un ejemplo que muestre cómo podemos definir nuestros propios tipos y extender funcionalidades de librerías existentes en Julia mediante el despacho múltiple. En concreto, vamos a crear un tipo de dato para representar series temporales y extender algunas funciones de las librerías estándar de Julia para trabajar con este nuevo tipo.
Vamos a definir dos tipos personalizados nuevos:
- `TimeSeries`: Para representar una serie temporal con fechas, valores y un nombre
- `FinancialData`: Para agrupar varias series temporales financieras (precios de apertura, cierre, etc.)
```Julia
using Statistics # Librería estándar para operaciones estadísticas
using Dates # Librería estándar para manejo de fechas
using LinearAlgebra # Librería estándar para operaciones con matrices
# Definimos un tipo personalizado para representar una serie temporal
struct TimeSeries{T<:Number}
dates::Vector{Date}
values::Vector{T}
name::String
# Constructor interno para validación
function TimeSeries(dates::Vector{Date}, values::Vector{T}, name::String) where T<:Number
if length(dates) != length(values)
throw(ArgumentError("Las fechas y valores deben tener la misma longitud"))
end
new{T}(dates, values, name)
end
end
# Definimos otro tipo para representar un dataset financiero
struct FinancialData
ticker::String
open::TimeSeries{Float64}
high::TimeSeries{Float64}
low::TimeSeries{Float64}
close::TimeSeries{Float64}
volume::TimeSeries{Int}
end
```
A continuación, extendemos algunas funciones de las librerías estándar de Julia para trabajar con nuestro nuevo tipo `TimeSeries`. Esto nos permitirá usar funciones como `mean`, `std`, y operadores como `+` y `*` directamente con nuestras series temporales.
Comenzamos por extender algunas funciones de la librería `Statistics` para calcular la media y la desviación estándar de una serie temporal.
```Julia
# Extendemos la función `mean` de Statistics
import Statistics: mean
function mean(ts::TimeSeries)
println("Calculando media de la serie temporal '$(ts.name)'")
return mean(ts.values)
end
# Extendemos la función `std` (desviación estándar) de Statistics
import Statistics: std
function std(ts::TimeSeries)
println("Calculando desviación estándar de la serie temporal '$(ts.name)'")
return std(ts.values)
end
```
Luego, extendemos algunos operadores básicos para que funcionen con nuestras series temporales. Por ejemplo, podemos sumar dos series temporales o multiplicar una serie por un escalar.
```Julia
# Extendemos la función `+` para combinar series temporales
import Base: +
function +(ts1::TimeSeries{T}, ts2::TimeSeries{T}) where T<:Number
# Verificamos que las fechas coincidan
if ts1.dates != ts2.dates
throw(ArgumentError("Las series temporales deben tener las mismas fechas"))
end
new_values = ts1.values .+ ts2.values
new_name = "$(ts1.name) + $(ts2.name)"
println("Sumando series temporales '$(ts1.name)' y '$(ts2.name)'")
return TimeSeries(ts1.dates, new_values, new_name)
end
# Extendemos la función `-` para restar series temporales
import Base: -
function -(ts1::TimeSeries{T}, ts2::TimeSeries{T}) where T<:Number
# Verificamos que las fechas coincidan
if ts1.dates != ts2.dates
throw(ArgumentError("Las series temporales deben tener las mismas fechas"))
end
new_values = ts1.values .- ts2.values
new_name = "$(ts1.name) - $(ts2.name)"
println("Restando series temporales '$(ts1.name)' y '$(ts2.name)'")
return TimeSeries(ts1.dates, new_values, new_name)
end
# Extendemos el operador * para multiplicar una serie temporal por un escalar
import Base: *
function *(scalar::Number, ts::TimeSeries{T}) where T<:Number
new_values = scalar .* ts.values
new_name = "$(scalar) * $(ts.name)"
println("Multiplicando serie temporal '$(ts.name)' por $scalar")
return TimeSeries(ts.dates, new_values, new_name)
end
# Implementación simétrica para permitir ts * scalar
function *(ts::TimeSeries{T}, scalar::Number) where T<:Number
return scalar * ts
end
# Extendemos la función `getindex` para poder indexar series temporales
import Base: getindex
function getindex(ts::TimeSeries, i)
println("Accediendo al índice $i de la serie temporal '$(ts.name)'")
return (ts.dates[i], ts.values[i])
end
# Extendemos la función `getindex` para permitir rangos de fechas
function getindex(ts::TimeSeries, date_range::StepRange{Date})
start_date = date_range.start
end_date = date_range.stop
indices = findall(d -> start_date <= d <= end_date, ts.dates)
if isempty(indices)
throw(ArgumentError("No hay datos para el rango de fechas especificado"))
end
println("Extrayendo datos de '$(ts.name)' para el rango $(start_date) a $(end_date)")
return TimeSeries(ts.dates[indices], ts.values[indices], ts.name)
end
# Extendemos la función `size` para obtener dimensiones de nuestra serie temporal
import Base: size
function size(ts::TimeSeries)
println("Obteniendo tamaño de la serie temporal '$(ts.name)'")
return (length(ts.dates),)
end
```
Finalmente, extendemos algunas funciones de la librería `LinearAlgebra` para calcular la norma de una serie temporal y también agregamos funciones personalizadas para calcular retornos logarítmicos y proporcionar un resumen estadístico de la serie temporal.
```Julia
# Extendemos función para calcular retornos logarítmicos
function log_returns(ts::TimeSeries{T}) where T<:Number
if length(ts.values) < 2
throw(ArgumentError("Se necesitan al menos dos valores para calcular retornos"))
end
# Calculamos log(p_t / p_{t-1})
new_dates = ts.dates[2:end]
new_values = log.(ts.values[2:end] ./ ts.values[1:end-1])
new_name = "log_returns($(ts.name))"
println("Calculando retornos logarítmicos de '$(ts.name)'")
return TimeSeries(new_dates, new_values, new_name)
end
# Extendemos función `describe` para dar un resumen estadístico
function describe(ts::TimeSeries)
println("Estadísticas de la serie temporal '$(ts.name)':")
println(" - Número de observaciones: $(length(ts.values))")
println(" - Rango de fechas: $(minimum(ts.dates)) a $(maximum(ts.dates))")
println(" - Media: $(mean(ts.values))")
println(" - Desv. estándar: $(std(ts.values))")
println(" - Mínimo: $(minimum(ts.values))")
println(" - Máximo: $(maximum(ts.values))")
end
# Extendemos la función `norm` de LinearAlgebra para calcular la norma de una serie temporal
import LinearAlgebra: norm
function norm(ts::TimeSeries{T}) where T<:Number
println("Calculando norma de la serie temporal '$(ts.name)'")
return norm(ts.values)
end
```
En resumen, el ejemplo extiende tres librerías estándar de Julia y agrega funcionalidades específicas para trabajar con series temporales. Aquí hay un resumen de lo que hemos hecho:
1. **Statistics**: Extiende funciones estadísticas para que trabajen con nuestro tipo `TimeSeries`:
- `mean(ts::TimeSeries)`: Calcula la media de los valores
- `std(ts::TimeSeries)`: Calcula la desviación estándar
2. **Base**: Extiende operadores básicos y funciones fundamentales:
- Operador `+`: Para sumar series temporales
- Operador `*`: Para multiplicar series por escalares
- Función `getindex`: Para indexar por posición o rango de fechas
- Función `size`: Para obtener dimensiones
3. **LinearAlgebra**: Extiende funciones matemáticas más avanzadas:
- `norm(ts::TimeSeries)`: Calcula la norma de los valores
4. **Funciones personalizadas**: funciones específicas para análisis financiero:
- `log_returns`: Calcula retornos logarítmicos
- `describe`: Proporciona un resumen estadístico completo
Esta aproximación presenta varias ventajas:
1. **Código expresivo**: Podemos escribir código como `mean(ts_cierre)` o `ts_cierre + ts_apertura` que es intuitivo y legible.
2. **Integración perfecta**: Nuestros tipos se integran de forma natural con el ecosistema de Julia.
3. **Especialización**: Podemos adaptar algoritmos generales para que funcionen de manera óptima con nuestros tipos.
4. **Extensibilidad**: Cualquier usuario puede extender más funciones sin modificar nuestro código original.
Podemos probar nuestras extensiones creando algunos datos de ejemplo y aplicando las funciones que hemos definido:
```Julia
# Creamos datos de ejemplo
fechas = Date(2023, 1, 1):Day(1):Date(2023, 1, 10)
precios_apertura = [100.0, 101.5, 102.3, 101.8, 103.5, 104.2, 103.8, 105.1, 106.3, 105.8]
precios_cierre = [101.2, 102.1, 101.9, 103.2, 104.0, 103.7, 104.9, 106.0, 105.5, 107.2]
volumen = [15000, 18200, 14500, 22000, 19000, 17500, 21000, 23500, 20000, 25000]
# Creamos series temporales
ts_apertura = TimeSeries(collect(fechas), precios_apertura, "Precio Apertura")
ts_cierre = TimeSeries(collect(fechas), precios_cierre, "Precio Cierre")
ts_volumen = TimeSeries(collect(fechas), volumen, "Volumen")
# Probemos nuestras extensiones
println("\n=== Extendiendo Statistics ===")
println("Media de precios de apertura: $(mean(ts_apertura))")
println("Desviación estándar de precios de cierre: $(std(ts_cierre))")
println("\n=== Extendiendo operadores aritméticos ===")
spread = ts_cierre - ts_apertura
println("Media del spread (cierre - apertura): $(mean(spread))")
println("\n=== Multiplicación por escalar ===")
ts_apertura_ajustada = 1.01 * ts_apertura # Ajuste del 1%
println("Media de precios ajustados: $(mean(ts_apertura_ajustada))")
println("\n=== Indexación ===")
fecha, valor = ts_cierre[5]
println("Precio de cierre del $(fecha): $(valor)")
println("\n=== Filtrado por rango de fechas ===")
rango_fechas = Date(2023, 1, 3):Day(1):Date(2023, 1, 7)
ts_cierre_rango = ts_cierre[rango_fechas]
println("Tamaño del rango filtrado: $(size(ts_cierre_rango)[1])")
println("\n=== Calculando retornos logarítmicos ===")
retornos = log_returns(ts_cierre)
println("Primera fecha de retornos: $(retornos.dates[1])")
println("Primer valor de retorno: $(retornos.values[1])")
println("\n=== Resumen estadístico ===")
describe(ts_cierre)
println("\n=== Usando LinearAlgebra ===")
println("Norma de los precios de cierre: $(norm(ts_cierre))")
```
Como hemos comentado, la composibilidad es un principio fundamental en Julia, que contrasta con el modelo de herencia utilizado en muchos lenguajes orientados a objetos. Vamos a mostrar un ejemplo claro que ilustra cómo funciona la composibilidad.
En Julia, en lugar de crear jerarquías de clases mediante herencia, se construyen comportamientos complejos mediante la composición de tipos y funciones más pequeñas.
Imaginemos que queremos crear un sistema para trabajar con diferentes formas geométricas.
**En un lenguaje basado en herencia** (como Java o Python), lo normal es que definamos una clase base `Forma` y luego subclases como `Círculo`, `Rectángulo`, etc. que heredan de ella:
```python
# Enfoque de herencia (Python)
class Forma:
def area(self):
pass
class Círculo(Forma):
def __init__(self, radio):
self.radio = radio
def area(self):
return 3.14159 * self.radio * self.radio
class Rectángulo(Forma):
def __init__(self, ancho, alto):
self.ancho = ancho
self.alto = alto
def area(self):
return self.ancho * self.alto
```
**En Julia, usando composibilidad**, el enfoque es diferente:
1. Definimos tipos para representar nuestras estructuras de datos.
2. Implementamos funciones genéricas que operan sobre estos tipos.
3. El comportamiento emerge de la composición de tipos y funciones.
```julia
# Enfoque de composibilidad (Julia)
# Definimos tipos simples
struct Círculo
radio::Float64
end
struct Rectángulo
ancho::Float64
alto::Float64
end
# Definimos una función genérica que trabaja con ambos tipos
function área(c::Círculo)
return π * c.radio^2
end
function área(r::Rectángulo)
return r.ancho * r.alto
end
# Ahora podemos usar estos tipos y funciones
mi_círculo = Círculo(5.0)
mi_rectángulo = Rectángulo(4.0, 6.0)
println("Área del círculo: $(área(mi_círculo))")
println("Área del rectángulo: $(área(mi_rectángulo))")
# Podemos componer nuevas funcionalidades fácilmente
function perímetro(c::Círculo)
return 2π * c.radio
end
function perímetro(r::Rectángulo)
return 2 * (r.ancho + r.alto)
end
# Podemos crear una función que trabaje con cualquier forma
function info_forma(forma)
println("Área: $(área(forma))")
println("Perímetro: $(perímetro(forma))")
end
```
Podemos destacar las siguientes ventajas de la composibilidad en Julia:
1. **Despacho múltiple**: Las funciones se seleccionan basándose en todos sus argumentos, no solo en el primer argumento como en la programación orientada a objetos tradicional.
2. **Extensibilidad**: Se pueden añadir nuevos métodos para tipos existentes sin modificar su definición original:
```julia
# Podemos añadir nuevas funcionalidades sin modificar las definiciones originales
function diagonal(r::Rectángulo)
return sqrt(r.ancho^2 + r.alto^2)
end
# Incluso podemos extender tipos de la librería estándar
import Base: *
function *(escala::Number, c::Círculo)
return Círculo(escala * c.radio)
end
círculo_grande = 2 * mi_círculo # Círculo con radio 10.0
```
3. **Combinación flexible**: Se pueden combinar comportamientos sin necesidad de jerarquías complejas de herencia:
```julia
# Podemos crear un tipo que contenga múltiples formas
struct GrupoFormas
formas::Vector{Any}
end
# Y definir comportamientos compuestos
function área_total(grupo::GrupoFormas)
return sum(área(forma) for forma in grupo.formas)
end
mis_formas = GrupoFormas([mi_círculo, mi_rectángulo])
println("Área total: $(área_total(mis_formas))")
```
Consideremos un algoritmo para calcular la distancia de escape en el conjunto de Mandelbrot, un problema computacionalmente intensivo típico en matemáticas.
**El enfoque tradicional con dos lenguajes**
**Versión Python (fácil de escribir pero lento):**
```python
def mandelbrot_distance(c, max_iter):
z = 0
for i in range(max_iter):
z = z*z + c
if abs(z) > 2:
return i
return max_iter
def generate_mandelbrot(width, height, x_min, x_max, y_min, y_max, max_iter):
result = []
for y in range(height):
for x in range(width):
# Mapear pixel a coordenadas complejas
real = x_min + (x_max - x_min) * x / (width - 1)
imag = y_min + (y_max - y_min) * y / (height - 1)
c = complex(real, imag)
# Calcular distancia
distance = mandelbrot_distance(c, max_iter)
result.append(distance)
return result
```
**Versión C++ (para optimizar el rendimiento):**
```cpp
#include
Pongamos un ejemplo en el que sí se observan mejoras sustanciales pero haciendo cambios (refactorizando) desde Julia, sin tener que salir a u lenguaje adicional pensado para velocidad (como C).
La multiplicación de matrices es un buen ejemplo para demostrar las diferencias de rendimiento entre implementaciones ingenuas y optimizadas, y cómo Julia cierra la brecha del problema de los dos lenguajes.
```julia
using BenchmarkTools
# 1. Implementación ingenua (estilo Python/MATLAB)
function mul_matrices_ingenua(A, B)
m, n = size(A)
n, p = size(B)
C = zeros(eltype(A), m, p)
for i in 1:m
for j in 1:p
for k in 1:n
C[i,j] += A[i,k] * B[k,j]
end
end
end
return C
end
# 2. Implementación mejorada (con optimizaciones manuales)
function mul_matrices_optimizada(A, B)
m, n = size(A)
n, p = size(B)
C = zeros(eltype(A), m, p)
# Pre-acceder a las columnas de B para mejorar la localidad de caché
B_cols = [view(B, :, j) for j in 1:p]
for i in 1:m
A_row = view(A, i, :)
for j in 1:p
B_col = B_cols[j]
s = 0.0
for k in 1:n
s += A_row[k] * B_col[k]
end
C[i,j] = s
end
end
return C
end
# 3. Usando la función incorporada de Julia
function mul_matrices_nativa(A, B)
return A * B
end
# Tamaño de las matrices
n = 1000
A = rand(n, n)
B = rand(n, n)
# Comparar tiempos
println("Método ingenuo (estilo Python/MATLAB):")
@btime mul_matrices_ingenua($A, $B);
println("\nMétodo optimizado manualmente:")
@btime mul_matrices_optimizada($A, $B);
println("\nMétodo nativo de Julia:")
@btime mul_matrices_nativa($A, $B);
# Verificar que los resultados son equivalentes
C1 = mul_matrices_ingenua(A, B)
C2 = mul_matrices_optimizada(A, B)
C3 = mul_matrices_nativa(A, B)
println("\nLos resultados son equivalentes: ",
isapprox(C1, C2) && isapprox(C2, C3))
```
Al ejecutar este código, se pueden ver diferencias de rendimiento significativas:
1. La versión ingenua será mucho más lenta (segundos o decenas de segundos).
2. La versión optimizada manualmente será mejor pero aún no óptima.
3. La versión nativa de Julia será órdenes de magnitud más rápida (milisegundos).
Para comprender mejor cómo Julia resuelve el problema de los dos lenguajes, consideremos el enfoque tradicional:
* En Python: El código ingenuo para multiplicación de matrices sería muy lento (segundos o minutos para matrices grandes).
* Solución tradicional: Reescribir el código en C/C++ o usar NumPy (que internamente usa código C optimizado).
* Con Julia: El código ingenuo es ya relativamente rápido (comparado con Python puro). Se puede optimizar incrementalmente dentro del mismo lenguaje. Las funciones nativas de Julia son tan rápidas como las implementaciones en C/C++.
Julia facilita la escritura de código matemático con apariencia similar a la notación matemática tradicional gracias a su amplio soporte de caracteres Unicode. Esto hace que el código sea más legible y cercano a las expresiones matemáticas formales.
**Consejos para usar Unicode en Julia**
1. **Completado con TAB**: Escribe el nombre LaTeX del símbolo precedido por `\` y presiona TAB.
- Ejemplo: `\alpha` + TAB → `α`
2. **Algunos símbolos comunes**:
- Letras griegas: `\alpha`, `\beta`, `\gamma`, `\delta`, `\epsilon`, etc.
- Operadores: `\sum`, `\prod`, `\int`, `\partial`, `\nabla`
- Comparadores: `\le`, `\ge`, `\ne`, `\approx`, `\equiv`
- Conjuntos: `\cup`, `\cap`, `\in`, `\notin`, `\subset`
- Subíndices y superíndices: `\_`, `\^`
3. **Caracteres especiales en el REPL de Julia**:
- Escribe `?` y luego presiona TAB dos veces para ver una lista completa de símbolos disponibles
- La ayuda del REPL también muestra cómo escribir símbolos específicos
Veamos un ejemplo completo que muestra varias formas de usar Unicode en Julia para representar conceptos matemáticos:
```julia
# Símbolos matemáticos en Julia usando Unicode
# Letras griegas
α = 0.5 # Alpha: escribir \alpha + TAB
β = 2.0 # Beta: escribir \beta + TAB
γ = 1.5 # Gamma: escribir \gamma + TAB
Δ = 0.1 # Delta mayúscula: escribir \Delta + TAB
π # Pi: escribir \pi + TAB (predefinido en Julia)
# Operadores matemáticos
x₁ = 10 # Subíndice: escribir x\\_1 + TAB
y² = 100 # Superíndice: escribir y\^2 + TAB
z₃⁴ = 81 # Combinación de sub y superíndices
# Operadores de conjuntos
A ∩ B # Intersección: escribir \cap + TAB
A ∪ B # Unión: escribir \cup + TAB
A ⊆ B # Subconjunto: escribir \subseteq + TAB
x ∈ A # Pertenencia: escribir \in + TAB
x ∉ B # No pertenencia: escribir \notin + TAB
# Símbolos de cálculo e integrales
∫ f(x) dx # Integral: escribir \int + TAB
∑_{i=1}^n i # Suma: escribir \sum + TAB
∂f/∂x # Derivada parcial: escribir \partial + TAB
∇f # Gradiente (nabla): escribir \nabla + TAB
# Comparadores
x ≤ y # Menor o igual: escribir \le + TAB
x ≥ y # Mayor o igual: escribir \ge + TAB
x ≠ y # Distinto: escribir \ne + TAB
x ≈ y # Aproximadamente igual: escribir \approx + TAB
# Ejemplo completo: resolver una ecuación cuadrática
function resolver_ecuación(a, b, c)
Δ = b² - 4a*c
if Δ < 0
return "No hay soluciones reales"
elseif Δ == 0
x₁ = -b/(2a)
return "Solución única: x = $x₁"
else
x₁ = (-b + √Δ)/(2a) # √ es el símbolo de raíz: escribir \sqrt + TAB
x₂ = (-b - √Δ)/(2a)
return "Dos soluciones: x₁ = $x₁, x₂ = $x₂"
end
end
# Ejemplo: resolver αx² + βx + γ = 0
println(resolver_ecuación(α, β, γ))
# Definición de función con notación matemática
function integral_aproximada(f, a, b, n)
h = (b-a)/n
∑ = 0.0
for i in 1:n
x = a + i*h
∑ += f(x)
end
return h * ∑
end
# Cálculo de π usando la integral ∫₀¹ 4/(1+x²) dx
π_aproximado = integral_aproximada(x -> 4/(1+x^2), 0, 1, 1000)
println("Valor aproximado de π ≈ $π_aproximado")
println("Error: |π - π_aproximado| ≈ $(abs(π - π_aproximado))")
```
Aquí hay un ejemplo más avanzado que muestra el cálculo matricial con notación matemática:
```julia
# Álgebra lineal con notación Unicode
using LinearAlgebra
# Matrices con notación especial
A = [1 2; 3 4]
B = [5 6; 7 8]
# Producto tensorial
A ⊗ B # Escribir \otimes + TAB
# Multiplicación de matrices
C = A * B
C′ # Transpuesta: escribir C\' + TAB
C† # Conjugada transpuesta: escribir C\dagger + TAB
# Valores propios y vectores propios
λ, ⃗v = eigen(A) # λ (lambda): escribir \lambda + TAB
# ⃗v (vector): escribir \vec + TAB
# Determinante y traza
det_A = det(A) # |A| en matemáticas
tr_A = tr(A) # Traza
# Norma de un vector
‖x‖₂ = norm([3, 4]) # ‖ ‖ escribir \|| + TAB
# Solución de sistemas lineales
x = A \ [1, 2] # Solución de Ax = [1, 2]
```
Ejemplo con ecuaciones diferenciales:
```julia
# Ecuaciones diferenciales con notación Unicode
using DifferentialEquations
# Definir una ecuación diferencial: dx/dt = αx - βx²
function logística!(dx, x, p, t)
α, β = p
dx[1] = α*x[1] - β*x[1]^2
end
# Condiciones iniciales y parámetros
x₀ = [0.5]
tspan = (0.0, 10.0)
p = (α, β) = (3.0, 1.0)
# Resolver la ecuación diferencial
problema = ODEProblem(logística!, x₀, tspan, p)
solución = solve(problema)
# Imprimir resultado
println("x(t=10) ≈ $(solución(10.0)[1])")
println("Valor teórico x(∞) = α/β = $(α/β)")
```