Reflexiones sobre cómo desarrollar software (y no morir en el intento)

Continuous delivery: cambiando nuestro código sin dejar de ofrecer un servicio

En este artículo presentamos un ejemplo práctico de cómo llevar a cabo cambios importantes tanto en nuestro código como en la base de datos utilizada para mantener la persistencia de la informació sin dejar de proporcionar servicio. De esta forma, podemos hacer efectivo el paradigma de continuous delivery sin que los usuarios de nuestro sistema sufran paradas en el servicio debido a las actualizaciones que llevamos a cabo.

Presentación del ejemplo utilizado

Para mostrar de una forma efectiva las técnicas aqui expuestas, vamos a utilizar un servicio que gestiona información relativa a clientes expuesto a través de un API REST. Dicho servicio guarda la siguiente información sobre los clientes:
  • Nombre
  • Apellidos
  • Calle
  • Número
  • Código postal
  • Ciudad
Estos datos son almacenados en una única tabla dentro de una base de datos relacional con la siguiente estructura:


Este servicio ha sido completamente implementado en Java, utilizando JPA para la gestión del almacenamiento persistente y Jersey para la creación de la interfaz REST. Existe una clase que modela a nuestros clientes:

Un repositorio de clientes:

Y un servicio de clientes:

Con objeto de mantener el código lo más sencillo posible, he optado por no usar Spring (las principales dependencias siguen siendo inyectadas aunque esto lo hago de forma manual sin ayuda de ningún framework):

Puedes ver el detalle de cómo se ha implementado la persistencia con JPA echándole un vistazo al código de las classes CustomerRepositoryImpl y PersistenceManager. También puedes descargar el código inicial de este proyecto aquí (utiliza la rama master).

El diseño de nuestra solución está lejos de ser perfecto por muchos motivos pero digamos que, hasta el momento, ha sido adecuado para nuestro propósito y lo estamos utilizando en producción. El código puede echarse a correr utilizando un servidor Jetty embebido arrancado con Maven:

mvn jetty:run -Djetty.port=[puerto donde queremos escuchar]

Si queremos que este servicio sea altamente disponible necesitaremos varias réplicas ejecutándose al mismo tiempo para que así un balanceador de carga pueda enviar las peticiones entrantes a cualquiera de las instancias en ejecución que considere oportuna (cómo configurar dicho balanceador de carga está fuera del alcance de esta entrada, aunque no es difícil).

Evolución del servicio de clientes

Una de las desventajas de esta solución es que solamente podemos conocer la dirección actual de un cliente, pero no las distintas direcciones que ha tenido a lo largo del tiempo.  Así que decidimos que, de ahora en adelante, nuestro sistema va a almacenar un registro de todas las direcciones que ha usado un cliente junto con la fecha en que cada dirección ha sido proporcionada.

Esto va a requerir modificar tanto nuestra aplicación como el esquema de la base de datos relacional que usa. Existen varias formas de hacer esto. Por ejemplo, podemos pensar en un modelo de datos nuevo que dé soporte a los nuevos requisitos:

Con una nueva tabla para direcciones (address) que tenga todos los campos actualmente contenidos en la tabla de cliente más una referencia (clave ajena) al cliente al que pertenece, modificamos nuestra aplicación y creamos un script de migración que cree la nueva tabla para direcciones con su correspondiente clave ajena a la tabla de clientes y migre los datos existentes, eliminando los campos de direcciones de la tabla de clientes ya que no son necesarios de ahora en adelante. Entonces paramos nuestros dos servidores en ejecución, actualizamos el código que ejecutan, actualizamos nuestra base de datos (cambio de esquema y migración de datos de clientes existentes) y volvemos a arrancar los servidores.

El principal inconveniente de esto es que nuestro servicio dejará de estar operativo el tiempo que dure la actualización de código y la migración de nuestra base de datos. Esto puede ser un gran inconveniente ya que, mientras la actualización del código tardará poco, la migración de los datos tendrá una duración variable, a menudo no conocida de antemano, y que dependerá en gran medida del volumen de datos que maneja nuestro servicio. Es decir, cuanto más datos tengamos en nuestra base de datos más tardaremos en migrarlos a la nueva estructura que le da soporte a los nuevos requisitos.

Es importante resaltar que, debido a la naturaleza del cambio que se quiere hacer, no es posible actualizar uno de los dos servidores y dejar el otro funcionando para mantener el servicio operativo durante la migración de datos. Sencillamente, el código nuevo no puede funcionar con el antiguo esquema de datos porque necesita la nueva tabla de direcciones, del mismo modo que el código antiguo no es compatible con el nuevo esquema porque necesita los campos de direcciones que hay en la tabla de clientes.

Por lo tanto, tenemos que adoptar un enfoque diferente si queremos hacer este cambio si queremos que siempre haya, al menos, un servidor en ejecución para poder garantizar que nuestro servicio está disponible. Para ello, debemos construir una secuencia de cambios compatibles que nos permitan mantener una versión antigua y otra actualizada de nuestro código en ejecución al mismo tiempo.

Cambiando nuestro código de forma compatible

Vamos a empezar añadiendo a la tabla de clientes una nueva columna para guardar la fecha de cambio de la dirección. Vamos a hacer que dicha columna pueda contener valores nulos y ni siquiera vamos a usarla desde nuestro código todavía. Simplemente, vamos a preparar nuestra base de datos para cuando queramos empezar a utilizarla. El cambio sería muy sencillo (y pequeño):

Una de las utilidades que hemos añadido a nuestro código para hacer los cambios más rápidos es la capacidad de actualizar el esquema de datos a través del plugin de liquibase para Maven.

De forma, nuestro proceso de actualización de servidores consistirá en:

  1. Parar un servidor
  2. Actualizar el código (git pull)
  3. Ejecutar el comando para migrar nuestra base de datos (mvn liquibase:update)
  4. Arrancar el servidor (mvn jetty:run -Djetty.port=7070)
  5. Parar el segundo servidor
  6. Actualizar el código (git pull)
  7. Arrancar el servidor (mvn jetty:run -Djetty.port=9090)

Cuando solamente hemos actualizado un servidor, podemos hacer varias llamadas a cada uno de los servidores (el que ya ha sido actualizado y el que todavía no lo ha sido) para comprobar que ambos funcionan correctamente, es decir, que el cambio es compatible con la versión antigua del código. Esto no es ninguna sorpresa ya que, hasta ahora, no hemos cambiado nada en el código, solamente hemos añadido una nueva columna a una tabla en nuestra base de datos.

curl http://localhost:7070/api/customers/1

{"currentAddress":{"city":null,"postalCode":null,"streetName":null,"streetNumber":null},"firstName":"Frank","lastName":"Freundlich"}

curl http://localhost:9090/api/customers/1

{"currentAddress":{"city":null,"postalCode":null,"streetName":null,"streetNumber":null},"firstName":"Frank","lastName":"Freundlich"}

A continuación, modificamos nuestra clase de clientes para, ahora sí empezar a utilizar el nuevo campo que habíamos creado en la tabla de clientes:

El servicio que actualiza los datos de clientes también es adaptado:

Volvemos a actualizar nuestros dos servidores de forma secuencial, siempre dejando, al menos, uno en ejecución. Podemos probar, por ejemplo, que los dos servidores siguien funcionando cuando uno ya ha sido actualizado y el otro todavía no (obviamente, sólo uno de ellos almacenará la fecha y hora de cambio de la dirección de un cliente, pero los dos funcionarán).

Lo siguiente que haremos es publicar los datos acerca de cuándo ha cambiado una dirección en nuestro servicio REST. Este cambio no debería representar ningún problema, ya que consiste en añadir un nuevo campo en nuestro objecto DTO que transporta la información relativa a direcciones y hacer un pequeño ajuste en la clase que hace las transformaciones entre nuestras entidades y nuestro DTO:

Ahora sí podemos empezar a hacer los grandes cambios que requiere el mantenimiento de un historial de direcciones. Será bastante complicado hacer esto manteniendo toda la información relativa a las direcciones en la misma clase que modela a cliente y almacenando dicha información en la misma tabla en nuestra base de datos.

Vamos a optar por crear una nueva clase que modele direcciones (Address) y persistirla en nuestra base de datos en una tabla independiente, distinta de la tabla de clientes. Sin embargo, como ya vimos antes, no podemos hacer todos los cambios al mismo tiempo si queremos mantener la compatibilidad entre distintas versiones que se estén ejecutando en nuestros servidores al mismo tiempo (recordemos que, durante el proceso de actualización de nuestros servidores, habrá un periodo de tiempo en el que un servidor ya ha sido actualizado y el otro todavia no, por lo que ambas versiones deben ser compatibles). Por ello, vamos a proceder del siguiente modo: primero haremos los cambios necesarios en nuestras clases (objetos en memoria) y, a continuación, los cambios necesarios en nuestras tablas (datos persistentes).

Así que vamos a crear una nueva clase para direcciones y vamos a hacer que la clase de clientes empiece a a trabajar con dichas direcciones manteniendo un historial de las mismas:

Como podemos ver, el cambio todavía no es completo, pues el historial de direcciones no se está guardando realmente de forma persistente, pero nuestro modelo en memoria ya está preparado para gestionar un historial de direcciones correctamente y, aún más importante, es totalmente compatible con el modelo que tenemos en nuestra base de datos.


Ahora vamos a crear la estructura necesaria en nuestra base de datos para persistir el historial de direcciones con una nueva tabla:

También necesitamos adaptar nuestro código:

Como queremos que los cambios sean compatibles entre distintas versiones, debemos aún mantener la antigua estructura conviviendo con la nueva al mismo tiempo. Esto lo conseguimos gracias a la implementación que hemos hecho del método updateAddress:

Esto significa que cuando una dirección cambia, dicho cambio se guarda en dos sitios al mismo tiempo:
  • En la nueva tabla de direcciones que guarda nuestro historial
  • En los antiguos campos para direcciones que había en la tabla de clientes
¿Por qué hacemos esto? Porque es la forma en que garantizamos que dos versiones de nuestra aplicación podrán acceder a la base de datos para consultar la dirección de un cliente y que siempre podrán encontrar dicha información.

Imaginemos qué pasaría si no lo hiciéramos así. Supongamos que no tenemos ninguna dirección almacenada para un cliente y recibimos una llamada al servicio que actualiza los datos de un cliente proporcionando una dirección. Si esta llamada fuera atendida por la versión nueva del sistema y únicamente almacenara la dirección en la nueva tabla, ¿qué pasaría si la siguiente consulta sobre dicho cliente fuera atendida por la versión antigua del sistema? Pues que buscaría la dirección del cliente en los campos dentro de la tabla de clientes y no encontraría nada. Por ello, la versión nueva del código debe preocuparse de guardar las direcciones en los dos sitios a la vez.

Otro aspecto importante que debemos destacar es que el sistema empezará a guardar las direcciones en la nueva tabla, pero seguirá utilizando como fuente de datos primaria para la consulta de direcciones los campos que hay en la tabla de clientes. Es decir, la nueva versión que estamos preparando guarda los datos en el antiguo formato y en el nuevo, pero los devuelve utilizando el antiguo método. Esta forma de hacer cambios corresponde a una técnica más general que podemos aplicar cuando queremos sustituir un sistema (o subsistema) minimizando riesgos. El procedimiento, de forma general, sería:
  1. Implantar el nuevo sistema que va a sustituir a uno ya existente
  2. Empezar a utilizar el nuevo sistema al mismo tiempo que seguimos usando el antiguo. Usar el antiguo sistema como fuente primaria (el nuevo sistema se mantiene en la sombra)
  3. Hacer el nuevo sistema nuestra fuente primaria. Seguimos utilizando el antiguo sistema, siendo éste el que ahora está en la sombra.
  4. Cuando estamos seguros de que el nuevo sistema funciona correctamente y ya no necesitamos el antiguo, prescindimos de él y eliminamos todas las referencias al mismo.

Podemos hacer la prueba de que todo funciona correctamente, manteniendo la compatilibidad entre versiones parando uno de los servidores, actualizando el esquema de nuestra base de datos y arrancando el servidor de nuevo. Las peticiones que se hagan a servidor con el nuevo código que actualicen la dirección de un cliente guardarán los nuevos datos tanto en las columnas de la tabla de clientes como en la nueva tabla de direcciones que hemos creado. Las que se dirijan al servidor con el antiguo código guardarán las nuevas direcciones únicamente en los campos de la tabla de clientes. Ambos servidores devolverán la información más actual que exista de la dirección de un cliente utilizando los campos de la tabla de clientes.

Después de esta comprobación, podemos actualizar el segundo servidor para completar el despliegue de la nueva versión. Como resultado, ahora nuestro sistema siempre guarda las direcciones nuevas en la tabla de direcciones, manteniendo un registros histórico de las direcciones utilizadas por un cliente (también almacena, por motivos de compatibilidad, dicha información en los antiguos campos existentes en la tabla de clientes).

Todo esto está muy bien, pero todavía existen direcciones que únicamente están almacenadas en los campos de la tabla de clientes (se crearon antes de que la nueva versión se pusiera en producción y no han sido actualizados desde entonces). Éste es el motivo por el que debemos seguir tomando los campos en la tabla de clientes como la fuente primaria cuando atendemos una consulta de datos sobre un cliente. Ahora debemos migrar la información acerca de direcciones a la nueva tabla. Dependiendo de con cuánta frecuencia se hayan actualizado las direcciones de clientes desde que la nueva versión que usa la nueva tabla de direcciones, el número de direcciones almacenadas únicamente en el antiguo formato será mayor o menor y, por lo tanto, el proceso de migración tardará más o menos. Nuestro objetivo, en todo caso, es que dicho proceso se haga, como siempre de una forma que no implique dejar de prestar nuestro servicio.

Podemos distinguir dos casos:
  • clientes con direcciones almacenadas únicamente en los antiguos campos exisistentes en la tabla de clientes
  • clientes con direcciones almacenadas tanto en los campos de la tabla de clientes como en la nueva tabla de direccciones
En el primer caso, basta con crear una nueva entrada en la tabla de direcciones con la misma información que teníamos en los campos de la tabla de clientes. Esto se puede hacer fácilmente con el siguiente script SQL:

¿Qué problema podemos tener con los clientes que ya tengan datos en la nueva tabla de direcciones? En principio, podría parecer que, si esto es así, dicha información tiene que ser la más actual, ya que ha sido creada por una versión del sistema más nueva. Sin embargo, debemos recordar que, durante un tiempo, hemos mantenido dos versiones de nuestro sistema en ejecución al mismo tiempo. Por lo tanto, es posible (y, dependiendo del tráfico que recibamos, puede que incluso probable) que haya habido casos como el siguiente:
  1. un cliente no tiene ninguna dirección
  2. recibimos una petición para actualizar su dirección
  3. dicha petición es atendida por una versión actualizada del sistema
  4. la dirección se guarda en la nueva tabla de direcciones así como en los campos existentes en la tabla de clientes
  5. recibimos una nueva petición para actualizar la dirección del mismo cliente
  6. dicha petición es atendida por la versión antigua del sistema
  7. la dirección se guarda únicamente en los campos existentes en la tabla de clientes
  8. la dirección del cliente no vuelve a actualizarse más
Como vemos, es posible que haya direcciones en los campos de la tabla de clientes que sean más actuales que los datos existentes en la tabla de direcciones. ¿Cómo podemos migrar los datos en estos casos? En este caso es donde se muestra realmente útil el haber introducido, con nuestro primer cambio, el campo con la fecha de actualización de la dirección en la tabla de clientes. Gracias a que tenemos las fechas en las que una dirección ha sido actualizada en ambas tablas (clientes y direcciones), es sencillo determinar cuál de ellas es la más actual en caso de que sean distintas. Basta comparar la fecha de cambio de dirección en la tabla de clientes con la fecha de cambio de dirección de la más reciente entrada en la tabla de direcciones:

Ahora tenemos todos nuestros servidores ejecutando la última versión, que guarda las direcciones en la tabla de direcciones, y hemos migrado los datos de direcciones a la tabla address, incluso aquellos que, estando únicamente en la tabla customer, pudieran ser más actuales. Este es el momento en el que podemos tomar la información de la tabla de direcciones como fuente primaria cuando atendemos consultas de datos de clientes.

Podemos ver cómo ahora devolvemos las direcciones utilizando únicamente la información de la tabla de direcciones. Aún así, seguimos almacenando las direcciones tanto en la tabla de direcciones como en las columnas de la tabla de clientes. Podemos decir que ahora es el antiguo sistema que almacenaba las direcciones en la tabla de clientes el que ha pasado a estar en la sombra (aunque sigue funcinando). No es una mala idea mantener el sistema antiguo en la sombra durante un tiempo antes de eliminarlo definitivamente. Algunas ventajas de hacerlo así son:
  • podemos comparar los resultados con ambos sistemas (si vemos discrepancias podría ser síntoma de que algo no funciona bien)
  • si algo sale muy mal con el nuevo sistema, resulta fácil volver a utilizar el sistema antiguo
  • puede haber otros equipos utilizando el sistema antiguo que necesiten algún tiempo hasta que se adapten al nuevo (de esta forma, tienen más tiempo para llevar a cabo su migración particular)
Por supuesto, en nuestro caso, el precio que tiene mantener los datos de direcciones tanto en la tabla de clientes como en la nueva tabla de direcciones es que nuestro código será más complejo, además de la duplicidad innecesaria de datos en la base de datos. Se trata, en todo caso, de un estado temporal que nos proporciona el beneficio de poder construir la funcionalidad del historial de direcciones, que requería de una refactorización grande en nuestro código y modelo de datos, sin haber dejado de prestar el servicio ni un solo minuto.

Ahora sí podemos dejar de usar las columnas de la tabla de clientes. Con objeto de hacer el cambio compatible entre distintas versiones para poder tener siempre, al menos, un servidor en ejecución, empezamos eliminando las referencias a dichas columnas en nuestro código:

Y después, cuando este cambio haya sido implantado en todos los servidores, podemos definitivamente eliminar las columnas relativas a direcciones de la tabla de clientes.

Ahora sí los cambios necesarios para mantener un historial de direcciones han sido completados. Hemos visto cómo podemos conseguir hacer cambios grandes en nuestro código sin necesidad de parar nuestro servicio, lo que ayuda a conseguir una alta disponibilidad.

Reflexión final

¿Merece la pena dar tantas vueltas para hacer un cambio que podríamos haber implementado en un único paso? La respuesta, como casi siempre, es "depende". Se trata de valorar qué es más importante para nosotros en cada momento. Posiblemente si estamos trabajando con un sistema todavía inmaduro en el que experimentamos constantemente con nuevas funcionaliades que requieren cambios muy grandes, quizá lo que más nos interese es que los cambios estén completamente implementados y en producción lo antes posible, aunque eso signifique que el sistema esté indisponible por algún tiempo cada vez que implantamos un cambio nuevo. Por otro lado, si no queremos que nuestros usuarios experimienten ninguna caída del servicio, o no nos los podemos permitir (por ejemplo, si es un API pública que ofrecemos a terceros y tiene que estar siempre disponible), posiblemente sea una buena idea tomar el camino no tan directo pero siempre compatible que nos garantiza que podemos actualizar nuestros servidores de forma escalonada.

Referencias

Puedes encontrar el código utilizado como ejemplo en este artículo aquí.
Continue Reading

Clasificación automática de textos

Imaginemos que queremos construir un servicio que recomiende artículos para leer basándose en las preferencias de los usuarios. Una forma de empezar es preguntando a los usuarios por qué tipo de noticias les interesa más (deportes, política, economía, etc.) y sugerirles noticias de aquellos temas que han elegido. Pero, si queremos enviar noticias de distintas fuentes, más o menos conocidas, primero vamos a tener que resolver un problema: ¿cómo sabemos a qué categoría pertenece una noticia? En esta entrada veremos una forma rápida y sencilla de hacer esto, con una implementación real que funciona gracias a la librería scikit-learn the Python. El resultado final será un programa capaz de clasificar un texto en función de otros textos que ha analizado previamente y que le han servido para aprender a clasificar textos. Hablamos de machine learning en estado puro.

Representación de documentos

Consideremos un texto (o documento) como un conjunto letras, que forman palabras, que, a su vez, forman párrafos, que, finalmente, componen un texto. Queremos que nuestro programa aprenda de qué trata cada documento**, para lo que vamos a necesitar representar los documentos de una forma que podamos procesar después.

Una forma muy común de representar documentos en problemas de clasificación es mediante una bolsa de palabras, que no es más que una estructura que contendrá todas las palabras que aparecen en el documento junto con el número de veces que aparece en el mismo. Veamos un ejemplo con un documento muy sencillo:

La política puede llegar a ser muy divertida.
Las mejores noticias sobre política directamente en su smartphone.

Este documento podría representarse así:

a
directamente
en
la
las
llegar
mejores
muy
noticias
política
puede
ser
smartphone
sobre
su
1
1
1
1
1
1
1
1
1
2
1
1
1
1
1

Como podemos ver, todas las palabras aparece una sola vez excepto la palabra política, que aparece dos.

Distancia entre dos documentos: cuánto se parecen dos artículos

Ahora que tenemos una forma de representar documentos, veamos cómo podemos medir cuánto se parecen entre sí, es decir, vamos a buscar una medida de similitud para dos documentos. Tomemos las dos bolsas de palabras que representan cada documento:

1
0
0
0
5
3
0
0
1
0
0
0
0
3
0
0
0
2
0
0
1
0
1
0
0
0

Hemos ordenado la lista con las apariciones de cada palabra de la misma forma en ambos casos, por lo que sabemos que una cierta palabra aparece una vez en el primer documento y tres veces en el segundo, otra aparce cinco veces en el primer document y dos en el segundo; otras sólo aparecen en uno de ellos o en ninguno (Llegados a este punto, realmente no nos importa qué palabras son).

Podemos medir la similitud de dos documentos multiplicando las apariciones de cada palabra en cada uno de los documentos entre sí y sumando los resultados para todas las palabras:

1*3+0*0+0*0+0*0+5*2+3*0+0*0+0*1+1*0+0*1+0*0+0*0+0*0 = 13

Ahora os podréis preguntar, ¿realmente esto funciona? Pongámosle cara a los ejemplos para entender un poco mejor lo que está pasando. Supongamos que los dos primeros documentos corresponden a dos artículos sobre deportes (concretamente fútbol) y que las palabras cuya frecuencia estábamos represetando eran las que siguen:

Messi
rifle
conflicto
paz
gol
portero
guerra
seguridad
regate
falta
política
guerrillero
banda
1
0
0
0
5
3
0
0
1
0
0
0
0
3
0
0
0
2
0
0
1
0
1
0
0
0

Supongamos que ahora queremos ver cómo de similar es el primer artículo con otro sacado de la sección de internacional del periódico, que trata sobre sobre un conflicto armado:

Messi
rifle
conflicto
paz
gol
portero
guerra
seguridad
regate
falta
política
guerrillero
banda
0
0
1
0
0
0
9
0
0
0
6
4
0

Usando la misma fórmula que en el caso anterior:

1*0+0*0+0*1+0*0+5*0+3*0+0*9+0*0+1*0+0*0+0*6+0*4+0*0 = 0

Tenemos que nuestra medida de similitud nos da en este caso un valor de cero. Es decir, los dos artículos de fútbol tienen una medida de similitud de 13 mientras que el primer artículo de fútbol y el que trata sobre el conflicto armado tienen una medida de similitud de cero. Este resultado concuerda con la idea intuitiva que tenemos acerca de cómo de parecidos son estos artículos.

El problema de la longitud de documentos

Es fácil darse cuenta de que esta forma de calcular el parecido entre dos documentos ponderará de forma distinta documentos con distinto tamaño. Imaginemos que los dos artículos de deportes son el doble de largos y que la aparición de las palabras se mantiene en la misma proporción (irreal pero ilustrativo). Las bolsas de palabras se parecerán a las siguientes:

2
0
0
0
10
0
6
0
0
2
0
0
0
0
6
0
0
0
4
0
0
2
0
2
0
0
0
0

Si calculamos la similitud de ambos ahora tendremos:

2*6+0*0+0*0+0*0+10*4+6*0+0*0+0*2+2*0+0*2+0*0+0*0+0*0 = 52

Como vemos, el número es sensiblemente superior al original, 13. Este problema podemos resolverlo normalizando el vector con las apariciones de cada palabra. Para ello, sólo tenemo que dividirlo por la raíz cuadrada de la suma de los cuadrados de cada elemento (en el caso del primero documento, es 6):

1/6
0
0
0
5/6
3/6
0
0
1/6
2
0
0
0

Priorizando las palabras distintivas

Mediante la normalización podemos librarnos del problema con documentos con distintos tamaños pero aún podemos mejorar nuestra forma de comparar documentos basándonos en las palabras que aparecen en ellos.

Hay palabras que se repetirán mucho en todos los documentos (verbos y sustantivos muy comunes, preposiciones, etc.) mientras que otras se repetirán mucho únicamente para aquellos documentos relacionados. Las primeras serán palabras comunes en el conjunto de todos los documentos que harán que la medida de similitud entre dos documentos cualesquiera sea más alta aún no estando relacionados. Las segundas son las que realmente nos interesa ponderar, pues son la que realmente diferencian documentos parecidos de documentos sin relación. Es decir, queremos ponderar aquellas palabras que:
  • Aparecen mucho en un documento
  • Aparecen poco en el conjunto de todos los documentos
Decimos que las palabras importantes son aquellas localmente comunes y globalmente raras. Así llegamos al vector de frecuencias de términos y frecuencia inversa en documentos (term frequency - inverse document frequency, tf-idf). El cálculo de dicho vector para un documento es sencillo. Partiendo del vector original donde ya teníamos la frecuencia de cada palabra en un documento. consideramos también un vector para las mismas palabras donde cada valor se calcula como:




Si miramos con detalle esta fórmula para cada palabra, veremos que para aquellas palabras que aparecen en casi todos los documentos el resultado será el logaritmo de un número cercano a 1, el cual se aproxima a cero, mientras que, para palabras que aparecen en pocos documentos, será el logaritmo de un número cada vez mayor conforme la palabra aparece en menos documentos (más rara es). El cálculo del vector tf-idf para un documento no es más que el resultado de multiplicar el primer verctor (el de frecuencias de palabras en un documento) por éste último.

Gracias a la ponderación que se hace de las palabras realmente importantes, nuestra medida de similitud entre dos documentos será mucho más precisa si utilizamos el vector tf-idf que si sencillamente usamos el vector de frecuencias de palabras.

Encontrando documentos parecidos

Supongamos que tenemos un conjunto de documentos y queremos saber cuál es el documento que más se parece a un documento cualquiera. Calculando el vector tf-idf para el primer documento así como para el conjunto de documentos que tenemos, y calculando el parecido entre documentos usando dicho vector tf-idf, el documento con un valor más alto según nuestra medida de similitud será el candidato que más se parece dentro de nuestro cuerpo de documentos, es decir, será el vecino más cercano.

Agrupando documentos parecidos

Hemos visto que podemos definir el documento más parecido a otro como el documento más cercano. Es decir, podemos usar nuestra medida de similitud como una medida de distancia entre documentos. Esto nos permite utilizar uno de los algoritmos más famosos en machine learning para agrupar documentos que se parecen: K-means clustering. Sin entrar en muchos más detalles, nos basta saber que mediante este algoritmo podemos agrupar un conjunto de elementos por cercanía entre los mismos. Como ya tenemos una medida de la distancia entre documentos, podemos utilizarlo para obtener grupos de elementos que minizan la distancia respecto del centro de la agrupación. Así, formaremos categorías de documentos que se parecen entre sí.

Una vez que tengamos los grupos formados para un conjunto de documentos (ejemplos que utilizaremos para el aprendizaje) podemos predecir a qué categoría pertenece un nuevo documento calculando su distancia al centro de cada uno de las categorías que tenemos. La categoría predicha será aquella cuyo centro está más cercano al documento.

Clasificando documentos con scikit-learn

Partamos de un conjunto de artículos de ejemplo extraídos de algunos periódicos online. Dichos artículos pertenecen a las secciones de deportes, ciencia y economía. Cada artículo está almacenado en un fichero de texto dentro de un directorio llamado igual que la categoría a la que pertenece el artículo.

Vamos a empezar leyendo todos los artículos y creando un diccionario de artículos y etiquetas (cada etiqueta será el nombre de la categoría a la que pertenece).


Y ejecutamos al función anterior proporcionando el directorio raíz donde se encuentra cada uno de los directorios con los artículos:


Creamos nuestra matriz tf-idf con el conjunto de artículos que tenemos. Para esto, vamos a utilizar la librería de Python scikit-learn:


Ahora que tenemos una representación adecuada del cuerpo de documentos de entrenamiento (tf-idf), ejectuamos nuestro algoritmo de aprendizaje (K-means clustering), para lo que también utilizaremos scikit-learn:


Ahora tenemos un clasificador (clf) que puede predecir la categoría de un artículo. Probamos con un conjunto nuevo de datos y medimos cómo de buena es la predicción:


Con los conjuntos de entrenamiento y prueba proporcionados, tenemos predicciones razonablemente buenas, teniendo en cuenta lo reducido de nuestro conjunto de artículos de ejemplos (no teníamos más de 50 artículos para ninguna de las categorías).

Podemos mejorar nuestro clasificador de una forma muy sencilla. Como vimos antes, tf-idf intenta obviar aquellas palabras que se repiten en muchos documentos y que, por lo tanto, no sirven para distinguirlos. Sin embargo, podemos hacerle la vida más fácil y obtener mejores resultados si sencillamente nosotros proporcionamos una lista de palabras que no queremos que se tengan en cuenta en absoluto. Por ejemplo, para artículos en español podríamos hacer lo siguiente:



Con este sencillo ajuste, nuestro clasificador obtiene ahora una fiablidad de más del 92%. ¿A que no ha sido difícil?


Hemos visto una técnica para clasificar documentos y una implementación que, pese a no llegar a 50 líneas de código, obtiene buenos resultados con menos de 50 artículos de ejemplo por categoría. Como vemos, cada vez utilizar técnicas de machine learning requiere menos esfuerzo. Con unas cuantas líneas de código en Python, podemos crear un prototipo bastante rápido y luego, si creemos que los resultados son prometedores, meternos más de lleno. ¿Queréis saber alguna aplicación que pueda tener un clasificador de artículos como el que hemos visto? Por ejemplo, para determinar automáticamente en qué idioma está escrito un artículo.

Código fuente y artículos de ejemplo

Puedes encontrar todo el código fuente utilizado en este artículo así como un conjunto de artículos para entrenar vuestro clasificador y comprobar su precisión en el siguiente repositorio en Gthub: https://github.com/joragupra/text-classifier.


** Realmente nos basta con que aprenda a reconocer documentos que tratan sobre el mismo tema.
Continue Reading

Tomando notas de forma efectiva (I)

Tengo la manía de querer recordarlo todo. Pero mi cerebro, como el de todos, está hecho más bien para analizar y componer ideas nuevas que para guardar un montón de información. Así que hago lo que casi todo el mundo ha hecho desde siempre: tomar notas. Es algo que, si no hago, creo que no estoy trabajando (o, peor todavía, que estoy trabajando sin organización y de forma poco efectiva). Puedes llamarme maniático, pero así de importante es para mí llevar un registro de lo que hago, lo que se me pasa por la cabeza, en qué empleo mi tiempo, etc.

¿Por qué quieres mantener un registro?

Se trata, en mi opinión, del elemento que más puede aumentar la eficacia de una persona con la mínima inversión de tiempo (y dinero) requerida: tomar notas y procesarlas correctamente. Desde que empecé a trabajar me di cuenta de que las personas que mejor hacían su trabajo, y a las que me gustaría parecerme, tomaban notas sin parar. Así que decidí ponerme a tomar notas (llegando a veces a excesos que llevaron a ganarme algún que otro mote gracioso). Esto es lo que hago.

Empezando por el principio: ponte a escribir

En mi opinión, el proceso consta de dos partes fundamentales: tomar notas  y procesarlas. No tendrás mucho que procesar si no has escrito nada. Así que el primer paso, y el más importante, es empezar a tomar notas durante tu vida ordinaria.
No te obsesiones con la forma en que deberías hacerlo, cómo organizarlas, qué estructura darles, etc. Eso no hará más que detenerte y que no empieces nunca. Además, no es importante cuando estás empezando (lo importante realmente es empezar). Al poco tiempo verás que ya has desarrollado una forma con la que te sientes cómodo y te resulta últil cuando estés revisándolas después. Así que empieza a escribir ya.

Cosas que van a ayudarte

Una vez has cogido algo de inercia y lo de coger notas se ha convertido en un hábito ordinario, empieza a mejorar la forma en que lo haces. En mi caso, considero que ayuda bastante hacer lo siguiente:
  • Dale un título descriptivo a tus notas. Basta con algo sencillo y no muy largo que te proporcione algo de contexto acerca de qué trata la anotación que viene a continuación.
  • Enumera las páginas. Así podrás localizar cualquier cosa rápidamente mediante un índice (explicado más adelante) o hacer referencias a otras notas.
  • Pon siempre la fecha de cuándo hiciste la anotación. Siempre que hagas la primera anotación del día, empieza poniendo la fecha.
  • Crea un índice. Reserva las primeras tres o cuatro páginas para el índice y ve rellenándolo conforme haces más y más anotaciones (esto lo suelo hacer una vez al día). Este índice debería contener, como mínimo, la página donde se encuentra la anotación, la descripción (título) y la fecha.
Siguiendo estas cuatro pautas ya tienes un cuaderno más organizado que el 90% de los que me he visto en mi vida. Creo, además, que resulta bastante sencillo: nada de usar distintos colores, poner pegatinas para marcar páginas especiales o dividir un cuaderno por temas. Sencillamente escribir siguiendo un orden cronológico manteniendo estos cuatro elementos.

Algunos detalles más

Usa símbolos para identificar notas especiales. A este respecto, intento que sean los mínimos necesarios: una exclamación para algo importante (!), algo que parezca una bombilla para una idea sobre la que quiero volver más adelante y un círculo para una tarea nueva que he pensado que tengo que hacer. Apunte: intento no utilizar la libreta para gestionar tareas, pero a veces es mejor escribirlas a mano que perderlas (sobre cómo gestiono mis tareas escribiré más adelante).
Otra cosa que ayuda es dejar un espacio (al menos una línea) al final de cada anotación. De esta forma podrás añadir alguna aclaración que se te ocurra posteriormente. En mi caso, lo más normal es que sea una referencia a una página posterior donde continúo tratando el mismo tema.

Olvídate de la tecnología y presta atención a los detalles

Quieres que el proceso de tomar notas sea rápido, que lo puedas hacer en cualquier momento y cualquier lugar y quieres que no te falle nunca: quieres papel y lápiz. No intentes tomar notas en utilizando una tablet o escribiendo directamente en tu ordenador portátil. No será ni la mitad de efectivo y la sensación de que tienes algo "definitivo" (ya no lo tienes "que pasar a limpio", ¿eh?- venga ya, chaval, que no estás en el instituto) hará que sea más difícil que vuelvas sobre tus notas más adelante. Y créeme, ésa es la parte donde esto se pone interesante, no te la puedes saltar.

Conclusión

Así es como termina la primera parte de esta serie. La próxima tratará de qué hacer con esa información que has estado recopilando. Ahí es donde esto cobra sentido pero, como dije antes, hay que empezar por el principio (y es mejor hacerlo bien).

Si tienes alguna técnica ninja especial y quieres compartirla, usa los comentarios. O, si lo prefieres, puedes criticar lo que he dicho si te parece que no sirve para nada. Escríbelo también: aquí no censuramos ni le decimos a la gente lo que tiene que pensar.
Continue Reading

Mitos, falacias, errores y paradojas temporales

Cuando escribes código (o cuando lo estás probando) te encuentras realidades que tienen distintos efectos en tu trabajo: desde hacerte perder (un poco de) tiempo a convertir tu sistema en un auténtico infierno donde no quieres pasar ni un minuto más del estrictamente necesario.
Una variedad especial que se me ha repetido continuamente son las relacionadas con el tiempo. Por diversos motivos, tratar con el tiempo en un sistema puede llegar a ser extremadamente complicado. La cosa se complica todavía más cuando olvidamos tener en cuenta los detalles más básicos, lo que nos lleva a la motivación de este artículo.


Este post es un resumen, sin seguir ningún orden particular, de algunos de los descuidos, malentendidos y paradojas que me he encontrado al trabajar con sistemas que trataban con fechas, horas y el paso del tiempo.
Lo que sigue es bien conocido por todo el mundo y cualquiera podrá decir, con razón, que olvidar estas cosas es de tontos. Sin embargo, me he visto más de una vez empantadado en mi propio software por culpa de ellas.

No todos los meses tienen el mismo número de días

Es algo que todos saben y, sin embargo, no es infrecuente encontrarte código que, por ejemplo, cuando queremos saber qué día es exactamente dentro de un mes hacen cosas como sumar siempre 30 días a la fecha actual (o sumar siempre 31 días que, para el caso, es igualmente inválido con carácter general).
Y es que mayo tiene 31 días, junio tiene 30, julio tiene 31 y agosto tiene también 31. Y, por supuesto, tenemos febrero, que tiene 28 días, la mayoría de las veces aunque...

No todos los febreros tienen 28 días

Algo que normalmente se aprende en la escuela de pequeño y que se suele olvidar todavía con más frecuencia que el apartado anterior. Pero sí, resulta que, por una mezcla de cienca e historia, algunos meses de febrero tienen 28 días y otros tienen 29. Y, como, colorario...

No todos los años tienen 365 días

Por el mismo motivo por el que hay meses de febrero más largos que otros (y como consecuencia de ello), hay años que tienen 366 días. Se llaman bisiestos, ocurren cada cuatro años y hasta podemos hacer un sencillo cálculo para saber si un año es bisiesto.
Pero no siempre lo tenemos esto en cuenta y, más veces de las que deberíamos, damos por sentado que, por ejemplo, si tenemos que programar algo para que se ejecute automáticamente dentro de un año exactamente, podemos sumar 365 días a la fecha actual y tendremos la fecha en que queremos que nuestra tarea sea ejecutada (error).

Y no todos los minutos tienen 60 segundos

Y por eso hay que meter un segundo aquí y allá de vez en cuando -aunque nunca hayas tenido que tratar con este problema directamente, hay quien se lo toma muy en serio.
De acuedo, éste ha sido un poco más friki de la cuenta. Volvamos con algunos errores más ordinarios, por ejemplo, cuando tratamos con intervalos de tiempo. Empecemos por uno muy simple.

Un intervalo de tiempo de una hora no siempre empieza y acaba en el mismo día

Como ocurre, por ejemplo, con el intervalo de una hora que empieza a las 23:48 de hoy, que terminará mañana. Sencillo,¿ verdad? No sé por qué me he encontrado código que daba por hecho lo contrario (por ejemplo, asumiendo que al sumar una hora a la hora actual tendrías otra hora en el mismo día que tenías al principio).

Tampoco todos los intervalos de una hora empiezan y terminan el mismo mes

Si, por ejemplo, el intervalo que empieza a las 23:48 resulta que lo hace el 30 de abril. Así que, ya sabéis, sumar una hora a un instante de tiempo puede dar lugar a un instante de tiempo en otro mes distinto. Más aún...

No todos los intervalos de una hora ni siquieran empiezan y terminan el mismo año

Cuando el intervalo del que hablamos antes, en vez de ser el 30 de abril, es el 31 de diciembre, ya la hemos liado.
Bueno, creo que ya he explotado bastante el ejemplo y la idea se capta. Pero, ¡ah, recuerda!
  • No todas las semanas empiezan y terminan el mismo mes
  • Ni el mismo año -eso de que el uno de enero caiga en lunes es lo menos frecuente. De hecho, no volverá a ocurrir hasta el 2018.
Es momento ahora de ponerse un poco más técnico. La siguiente es obvia pero provoca bastantes problemas.

El reloj del sistema no siempre tiene la hora correcta

Y es que hay muchas formas de que te dé una hora equivocada. Por ejemplo cuando

El reloj del sistema está configurado como si estuviera en una zona horaria diferente

Y es que al que montó la máquina no le pareció importante asignar la zona horaria correcta cuando instaló el sistema operativo en el servidor de pruebas. Así que la hora parecerá la misma, y servirá para casi todo igual, pero llegará un momento en que tengas que tener en cuenta la zona horaria y, de repente, todo empezará a parecer que ocurre dos horas antes (o dos horas después, o lo que sea) que cuando debería ocurrir.
O también puede ser que sencillamente...

El reloj del sistema puede ser aleatoriamente inconsistente

No tiene sentido, pero ves que el sistema está configurado con un reloj exactamente adelantado 1 hora y 37 minutos y eso ha hecho que pierdas dos horas intentado encontrar la causa de que las horas de los cambios hechos en una tabla de tu base de datos sean incorrectas. Y entonces es cuando uno que pasa por ahí te dice que ayer cambió la hora del sistema porque quería ver si un proceso batch que normalmente se ejecuta a las seis de la tarde terminaba correctamente y se tenía que ir a casa a las cinco y cuarto.
Hablando de relojes cambiados, recuerda que:

La hora en tu servidor no tiene que coincidir con la hora que tienen los clientes

Y es que quien se conecta puede ser un mentiroso y haber cambiado su reloj para que parezca que solicitaron una oferta antes de que terminara el plazo en que expiraba.
O puede que sencillamente ellos viven en Sindey, tú en Madrid y tu servidor está en alguna parte de Irlanda. Lo que nos lleva a un subtipo especial.

Los relojes de tu servidor y de los clientes no siempre están en la misma zona horaria

Así que usa la differencia entre ambas zonas horarias para hacer tus cálculos. Y cuando los hagas, acuérdate también de tener en cuenta en qué día y qué mes estamos porque...

La diferencia entre regiones situadas en zonas horarias distintas no es siempre la misma

Puede variar a lo largo del año, por ejemplo, si en una zona aplican el horario especial de verano mientras que en otra, no.
Y es que trabajar con el tiempo nunca fue fácil y son muchas las cosas que hay que tener en cuenta, por lo que no es tan infrecuente que pasemos por alto algo que luego nos dará problemas. Espero que ahora que las he puesto por escrito no se me olviden más.
Continue Reading