#!/usr/bin/env python
# coding: utf-8
#
Guessed Style! v2.1
#
# **Guessed Artist!** - Un proyecto sencillo para la clasificación de obras de arte usando Deep Learning
#
# > **NOTA:** Este notebook retoma el trabajo en el notebook **guessed-style** obteniendo un 1% de acierto más, y añadiendo predicción para imágenes individuales, con ejemplos (el otro notebook se conserva por mantener los resultados que se publicaron en un artículo). Para la clasificación por autor, consultar el notebook **guessed-artist**.
# ## Introducción
# ### Motivación
# Hace unos meses tuve la suerte de visitar una [exposición](https://www.museothyssen.org/exposiciones/monetboudin) conjunta sobre [Claude Monet](https://www.wikiart.org/es/claude-monet) y [Eugène Boudin](https://www.wikiart.org/es/eugene-boudin). El primero, máximo exponente del *Impresionismo*, es uno de mis pintores favoritos (incluso le copié algún cuadro cuando era un chaval); pero sorprendentemente no conocía nada del segundo. Resulta que Monet fue *discípulo* de Boudin (empezó a pintar gracias a él), aunque acabó eclipsando totalmente a su maestro. Ambos mantuvieron una larga relación de amistad, desencuentros, y admiración mutua por encima de todo.
#
# En la exposición se podía comprobar la evolución de los dos pintores, desde un mismo punto de partida y misma temática, pero siguiendo distintos caminos, que con el tiempo volvieron a entrecruzarse. Resultado: una visita de casi 1 hora donde lo primero que hacías al ponerte delante de un nuevo cuadro era intentar adivinar su autor. ¡Pero no era nada sencillo!
#
# Revisando la aplicación de redes neuronales convolucionales al reconocimiento de imágenes me vino a la cabeza aquella exposición. ¿Cómo de difícil resultaría para estas redes la tarea de distinguir el autor de un cuadro? ¿Será mucho más complicado que distinguir animales en fotografías? Vamos a comprobarlo :)
# ### Los datos
# Para poder entrenar a nuestra red neuronal necesitaremos las fotos de muchos cuadros. Por suerte contamos con un dataset de obras de arte recopilado de [WikiArt](http://www.wikiart.org/) para una [competición de Kaggle](https://www.kaggle.com/c/painter-by-numbers/overview). Las imágenes de este dataset tienen copyright, pero se permite su uso para la minería de datos. Cuenta con algo más de 100.000 pertenecientes a 2.300 artistas, cubriendo un montón de estilos y épocas. Cada imagen está etiquetada en un fichero csv que obtendremos aparte.
#
# **NOTA**: Por rendimiento usaremos una versión preprocesada del dataset, con imágenes más pequeñas obtenidas de [aquí](https://www.kaggle.com/c/painter-by-numbers/discussion/23099).
# ### Las herramientas
# Para este pequeño proyecto vamos a utilizar la librería [fastai](https://docs.fast.ai/), que corre sobre [PyTorch](https://pytorch.org/). Fastai simplifica el entrenamiento de redes neuronales aplicando técnicas punteras de forma eficiente, y obteniendo resultados a la altura de los últimos avances en visión artificial, procesamiento del lenguaje natural, datos tabulares y filtrado colaborativo.
# ## Manos a la obra
# ### Inicializaciones
# Dependiendo del entorno realizamos una inicialización previa:
# In[ ]:
get_ipython().run_cell_magic('capture', '', "from notebook import notebookapp\nserver = list(notebookapp.list_running_servers())[0]\n\nif server['hostname'] == 'localhost':\n # Local environment\n %reload_ext autoreload\n %autoreload 2\n %matplotlib inline\nelse:\n # Cloud\n !pip install git+https://github.com/fastai/fastai.git\n !curl https://course.fast.ai/setup/colab | bash\n")
# Hacemos los imports necesarios (el uso del asterisco no está recomendado pero es útil a la hora de probar una nueva librería).
# In[ ]:
from fastai.vision import *
from fastai.metrics import error_rate
from fastai.utils.show_install import *
from shutil import copy, move
from io import BytesIO
from urllib.request import urlopen
from zipfile import ZipFile
import pandas as pd
import numpy as np
import os
import requests
import json
np.random.seed(42)
# In[3]:
# Show info about the environment. Useful when you are in Colab
# and you wanna know if you were assigned the best GPU ;)
show_install()
# ### Obtención de los datos
# Vamos a usar la versión *lite* del dataset original, que contiene todas las imágenes pero con un tamaño reducido, a sabiendas de que para entrenar nuestra red usaremos tamaños como 224x224 o 299x299 píxeles.
#
# Las imágenes reducidas se encuentran en un [repositorio de github](https://github.com/zo7/painter-by-numbers/releases/), divididas entre un fichero comprimido con las imágenes de entrenamiento y otro con las de test. Descargaremos ambos, los descomprimiremos, y copiaremos todas las imágenes a una misma ubicación.
# In[4]:
train_dir = untar_data('https://github.com/zo7/painter-by-numbers/releases/download/data-v1.0/train')
train_dir, len(train_dir.ls())
# In[ ]:
base_dir = train_dir.parent
pictures_dir = base_dir/'pictures'
pictures_dir.mkdir(exist_ok=True)
# In[6]:
for f in train_dir.ls():
copy(f, pictures_dir)
len(pictures_dir.ls())
# In[7]:
test_dir = untar_data('https://github.com/zo7/painter-by-numbers/releases/download/data-v1.0/test')
test_dir, len(test_dir.ls())
# In[8]:
for f in test_dir.ls():
copy(f, pictures_dir)
len(pictures_dir.ls())
# Este es el número exacto de cuadros incluidos en nuestro dataset.
#
# A continuación descargamos el fichero CSV con toda la información asociada a las imágenes (autor, estilo, género, nombre del fichero, etc). El fichero está alojado en mi repositorio de github por sencillez, pero se obtuvo previamente de [Kaggle](https://www.kaggle.com/c/painter-by-numbers/data):
# In[ ]:
zip_url = 'https://github.com/pyjaime/guessed-artist/raw/master/data/all_data_info.csv.zip'
with urlopen(zip_url) as zip_res:
with ZipFile(BytesIO(zip_res.read())) as zfile:
zfile.extractall(base_dir)
# Cargamos la información del CSV en un dataframe de Pandas:
# In[ ]:
csv = base_dir/'all_data_info.csv'
df = pd.read_csv(csv, low_memory=False)
# Vemos qué tipo de datos contiene de forma rápida:
# In[11]:
df.head()
# ## Clasificación por estilo (v2)
# Lo primero que vamos a tratar de hacer con los datos, a modo de *entrenamiento* y por curiosidad, es crear un modelo para clasificar los cuadros **por estilo**. A priori contamos con dicha información en la columna `style`. Veamos qué contiene exactamente.
# ### Preprocessing
# La columna `style` cuenta con un montón de categorías diferentes que en algunos casos parecen incluso una broma:
# In[12]:
style_counts = df['style'].value_counts()
print(len(style_counts), style_counts.keys())
# Nosotros usaremos sólo los estilos más repetidos en el dataset; ya que en mi opinión las categorías están demasiado atomizadas (de hecho yo sería aún más selectivo).
#
# **NOTA:** En esta segunda versión cogeremos 25 estilos en lugar de 16, y usaremos 750 muestras de cada uno.
# In[13]:
style_labels = style_counts[style_counts > 750].keys().to_list()
len(style_labels)
# Vemos la lista de estilos íntegra, junto al número de obras en cada uno de ellos (más que suficiente):
# In[14]:
df.groupby('style')['size_bytes'] \
.aggregate(['count']) \
.sort_values(by=['count'], ascending=False).query('count > 750')
# In[ ]:
# Uncomment this if you want to use a specific list of styles
#style_labels = ['Pointillism', 'Cubism']
# Creamos un nuevo dataframe usando sólo los estilos seleccionados:
# In[16]:
df_styles = df.query('style in @style_labels')
len(df_styles)
# Para que el dataset esté balanceado y el procesamiento sea más rápido, hacemos un muestro para quedarnos con el mismo número de imágenes por estilo:
# In[ ]:
sample_size = 750
# In[18]:
df_styles = df_styles.groupby('style')['style','new_filename'].apply(lambda s: s.sample(sample_size))
len(df_styles)
# In[19]:
df_styles.tail()
# Aquí es donde entra en juego la librería **fastai**. Con una única línea de código conseguiremos:
# * crear los subconjuntos de entrenamiento y validación a partir del dataframe anterior, usando la estructura de Imagenet.
# * asignar una serie de [transformaciones](https://docs.fast.ai/vision.transform.html#get_transforms) aleatorias para las imágenes (`ds_tfms`), que funcionan bastante bien en muchos escenarios. *(Ver párrafo siguiente)*.
# * configurar el tamaño de las imágenes a enchufar a nuestra red (`size`).
# * asignar el tamaño del batch (`bs`) para entrenamiento
# * aplicar normalización; algo deseable cuando vamos a usar una red neuronal (`normalize()`).
#
# El *aumento de datos* (**data augmentation**) es posiblemente la técnica de regularización más importante cuando se entrena un modelo para reconocimiento de imágenes. En lugar de alimentar nuestra red con las mismas imágenes una y otra vez, hacemos pequeñas transformaciones aleatorias (un poco de rotación, traslación, zoom, etc.) que no cambie lo que está dentro de la imagen a simple vista, pero sí que cambie los valores de sus píxeles. Los modelos entrenados con esta técnica generalizarán mejor. En nuestro caso igual puede tener menos sentido que en otros escenarios, ya que los cuadros *no se mueven*. Pero si pensamos en la posibilidad de poner nuestro modelo en producción, donde cualquiera pudiera enviar una imagen hecha con el móvil, seguro que vamos a querer aplicar esta técnica.
# In[20]:
data = ImageDataBunch.from_df(df=df_styles, path=pictures_dir,
label_col='style', fn_col='new_filename',
ds_tfms=get_transforms(), size=299, bs=48
).normalize(imagenet_stats)
data
# Vemos una pequeña muestra de los datos, con su etiqueta correspondiente:
# In[21]:
data.show_batch(rows=4, figsize=(10,10))
# ### Training: resnet-50
# Para llevar a cabo nuestra tarea, necesitamos elegir primero el tipo de red neuronal, y la arquitectura subyacente.
#
# Nos basaremos en una red neuronal convolucional (**CNN**), debido a su rendimiento contrastado en clasificación de imágenes. Para la arquitectura no partiremos de cero, sino que usaremos un modelo pre-entrenado sobre ImageNet (un dataset con más de 1 millón de imágenes), que ya sabe reconocer muchas cosas. Más concretamente usaremos **ResNet-34** y **ResNet-50** (el nº hace referencia a las capas). Por tanto, estaremos aplicando lo aprendido con ImageNet a nuestra red (**Transfer Learning**), y la empezaremos a entrenar con las imágenes de pinturas, para obtener unos resultados a la altura del estado del arte, nunca mejor dicho.
#
# Para llevar a cabo el entrenamiento en **fastai** sólo tendremos que ejecutar unas líneas. Primero instanciaremos un `Learner` especializado en CNN, especificando la arquitectura base. En este apartado usaremos directamente ResNet-50:
# In[22]:
learner = cnn_learner(data, models.resnet50, metrics=[error_rate])
# A continuación usaremos la función `fit_one_cycle()` del Learner, para llevar a cabo el entrenamiento de nuestro modelo usando un algoritmo rapidísimo para arquitecturas complejas ([más](https://sgugger.github.io/the-1cycle-policy.html) sobre **1cycle**). El parámetro principal es el número de *epochs* a ejecutar:
# In[23]:
learner.fit_one_cycle(6, callbacks=[callbacks.SaveModelCallback(learner, every='improvement', mode='min',
monitor='error_rate', name='style-r50-stage-1')])
# Entrenando el modelo simplemente modificando las capas adicionales conseguimos un acierto del 54,19%. Pero esto es sólo la fase 1; vamos a por la segunda.
# In[ ]:
learner.load('style-r50-stage-1') # uncomment this if you want the best model
#learner.save('style-r50-stage-1') # uncomment this if you want the last model
# Intentamos encontrar cuál es la tasa de aprendizaje para la siguiente fase:
# In[27]:
learner.lr_find()
learner.recorder.plot()
# La idea en esta segunda fase es *descongelar* los pesos de las capas pertenecientes a la arquitectura de partida, para volver a entrenar nuestro modelo completo. De esta forma podremos mejorar un poco más nuestro clasificador.
#
# La clave está en elegir la tasa de aprendizaje máxima para las distintas capas del modelo. Con esta gráfica elegiremos basicamente la tasa máxima para las primeras capas (una tasa pequeña, ya que dichas capas no necesitan mucho ajuste). La tasa máxima para las últimas capas se suele elegir unas 10 veces menor que la elegida para la primera fase (*3e-3* por defecto), teniendo en cuenta que nunca empeore el coste en la gráfica.
#
# Descongelamos los pesos y volvemos a entrenar el modelo:
# In[28]:
#learner.load('style-r50-stage-1')
learner.unfreeze()
learner.fit_one_cycle(7, max_lr=slice(8e-6,3e-4),
callbacks=[callbacks.SaveModelCallback(learner, every='improvement', mode='min',
monitor='error_rate', name='style-r50-stage-2')])
# Vemos cómo llega un momento en que el coste de validación deja de bajar. Nos quedaremos con el modelo en ese punto (*epoch 6*). El acierto de nuestro clasificador se queda en un **57,91%**, que no está mal si tenemos en cuenta la dificultad de la tarea. ¡Desde luego que a mí me supera!
# In[ ]:
learner.load('style-r50-stage-2');
# ### Results
# Vamos a interpretar los resultados de forma rápida usando un par de herramientas básicas.
#
# Primero pintamos las decisiones más fallidas de nuestro clasificador:
# In[ ]:
interp = ClassificationInterpretation.from_learner(learner)
losses,idxs = interp.top_losses()
# In[30]:
fig_top_losses = interp.plot_top_losses(9, heatmap=False, figsize=(18,15), return_fig=True)
fig_top_losses.dpi = 55
# En estos ejemplos (el *bottom-9*) nuestro clasificador arrojó una probabilidad nula de que el estilo del cuadro fuera el que realmente es.
#
# Pintamos la matriz de confusión para obtener una visión general de los errores:
# In[31]:
interp.plot_confusion_matrix(figsize=(10,10), dpi=70)
# Con esta gráfica podemos observar dónde se está equivocando nuestro clasificador exactamente.
#
# Existen varios estilos donde falla con mucha frecuencia. Así a primera vista no me extraña para nada su confusión entre:
# * Expresionismo abstracto e Informalismo
# * Neoclasicismo y Academicismo (¡para la Wikipedia son lo mismo!)
# * Impresionismo, Post-Impresionismo y Expresionismo
# * Romanticismo y Realismo
# * Barroco y Rococó
# * Los distintos *estilos* dentro del Renacimiento
#
# Es curioso, pero creo que coincido con la red neuronal en el estilo más sencillo de diferenciar: ¡el [Ukiyo-e](https://es.wikipedia.org/wiki/Ukiyo-e)!
#
# Vamos a ver el ránking de categorías más equivocadas:
# In[32]:
interp.most_confused(min_val=10)
# No es nada sencillo ubicar determinadas obras, y estoy seguro de que las personas que etiquetaron estos cuadros también tuvieron (y tendrían ahora mismo) sus dudas al hacerlo. Igual que no se puede etiquetar toda la obra de un pintor en una sola categoría, la delimitación entre estilos no es tan clara como puede ser en otros ámbitos, y hay pinturas que podrían perfectamente encajar bajo más de una etiqueta; así como categorías que yo creo que atienden más a formalismos que al estilo en sí.
#
# Aún así los resultados conseguidos (57,91% de acierto) son muy buenos, comparando con lo que se consideraba *estado-del-arte* hace muy poco tiempo ([2015](https://arxiv.org/pdf/1505.00855.pdf), [2017](https://www.lamsade.dauphine.fr/~bnegrevergne/webpage/documents/2017_rasta.pdf)), y teniendo en cuenta que no hemos hecho gran cosa para mejorar los datos de entrada, y prácticamente nada para modelar la red en sí misma (aunque esto es mérito de la librería fastai y del campo de la investigación).
#
# Seguramente se podrían mejorar los resultados usando más imágenes, recortes de las mismas, unas categorías más generalistas, otras transformaciones que no sean las de por defecto, etc. Pero no era el propósito de este proyecto.
#
# Y después de comprobar lo complicado que resulta asignar un cuadro a un estilo determinado... ¿Qué tal si probamos ahora con nuestra intención inicial de reconocer al autor de una obra? En ese caso la etiqueta o variable dependiente no será tan subjetiva :)
#
# > **NOTA:** Este notebook aporta una segunda aproximación al problema de la clasificación por estilo, pasando de 16 a 25 categorías, y usando más muestras por cada uno de ellos. Para la clasificación por autor, consultar el notebook **guessed-artist**.
# ## Clasificando una nueva imagen
# Pues eso
# In[ ]:
def print_pred_probs(pred, k):
topk = torch.topk(pred[2], k)
for i in range(3):
print(f'{learner.data.classes[topk.indices[i]]}: {100*topk.values[i]:.2f}%')
# In[164]:
img = open_image('data/the_scream.jpg')
img.show(figsize=(5, 7))
# In[165]:
pred = learner.predict(img)
print_pred_probs(pred, 3)
# El resultado de nuestro clasificador fue bastante claro.
#
# Pero nosotros sabemos que el cuadro original de Munch pertenece al Expresionismo. ¿Qué tal si recortamos la imagen para dejar fuera el bicho enorme de arriba?
# In[166]:
img = open_image('data/the_scream_crop.jpg')
img.show(figsize=(5, 7))
# In[167]:
pred = learner.predict(img)
print_pred_probs(pred, 3)
# El recorte es clasificado dentro del Expresionismo, con más confianza incluso que en la anterior decisión. Bueno... en realidad se parece demasiado a la obra original, así que no es de extrañar este resultado.
#
# ¿Y por qué no probar con uno de mis cuadros? Voy a escoger el único del que tengo una foto a mano (copia de otro):
# In[168]:
img = open_image('data/mine.png')
img.show(figsize=(8,6))
# In[169]:
pred = learner.predict(img)
print_pred_probs(pred, 3)
# En mi humilde opinión este cuadro no encaja mucho dentro del Expresionismo. Además el clasificador tiene sus dudas entre 2 estilos, con poca confianza en la decisión. Quizá soy un artista demasiado ecléctico xD xD