Laboratorio 8: ¿Superhéroe o Villano? 🦸

MDS7202: Laboratorio de Programación Científica para Ciencia de Datos

Cuerpo Docente:¶

  • Profesor: Matías Rojas y Mauricio Araneda
  • Auxiliar: Ignacio Meza D.
  • Ayudante: Rodrigo Guerra

Equipo: SUPER IMPORTANTE - notebooks sin nombre no serán revisados¶

  • Nombre de alumno 1: Johnny Godoy

Link de repositorio de GitHub: https://github.com/johnny-godoy/laboratorios-mds/blob/main/lab%208/laboratorio_8.ipynb¶

Temas a tratar¶

  • Codificación de texto usando Bag of Words.
  • Búsqueda del modelo óptimo de clasificación usando GridSearch
  • Uso de pipelines.

Reglas:¶

  • Grupos de 2 personas
  • Ausentes deberán realizar la actividad solos.
  • Cualquier duda fuera del horario de clases al foro. Mensajes al equipo docente serán respondidos por este medio.
  • Prohibidas las copias.
  • Pueden usar cualquer matrial del curso que estimen conveniente.

Objetivos principales del laboratorio¶

  • Obtener caracteristicas a partir de texto usando CountVectorizer.
  • Fijar un pipeline con un modelo base que luego se irá optimizando.
  • Comprender como realizar una búsqueda de grilla sobre un conjunto de clasificadores e hiperparámetros usando GridSearch.

El laboratorio deberá ser desarrollado sin el uso indiscriminado de iteradores nativos de python (aka "for", "while"). La idea es que aprendan a exprimir al máximo las funciones optimizadas que nos entrega pandas, las cuales vale mencionar, son bastante más eficientes que los iteradores nativos sobre DataFrames.

In [1]:
# Librería core del lab.
import numpy as np
import pandas as pd

# Pre-procesamiento
from sklearn.compose import ColumnTransformer
from sklearn.feature_selection import SelectPercentile, chi2
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.preprocessing import LabelEncoder, MaxAbsScaler, MinMaxScaler

# Modelamiento
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import ExtraTreesClassifier
from sklearn.naive_bayes import ComplementNB, MultinomialNB
from sklearn.pipeline import make_pipeline, Pipeline
from sklearn.svm import SVC

# Evaluación
from sklearn.experimental import enable_halving_search_cv
from sklearn.metrics import classification_report, roc_auc_score
from sklearn.model_selection import GridSearchCV, HalvingGridSearchCV
from sklearn.model_selection import train_test_split

# Librería para plotear
import plotly.express as px

# NLP
import nltk
from nltk import word_tokenize
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer
In [2]:
nltk.download('stopwords')
nltk.download('punkt')

pd.options.plotting.backend = "plotly"
[nltk_data] Downloading package stopwords to
[nltk_data]     C:\Users\David\AppData\Roaming\nltk_data...
[nltk_data]   Package stopwords is already up-to-date!
[nltk_data] Downloading package punkt to
[nltk_data]     C:\Users\David\AppData\Roaming\nltk_data...
[nltk_data]   Package punkt is already up-to-date!

1. ¿Quien es Bat Cow?¶

En vez de estar desarrollando las evaluaciones correspondientes a su curso, su profesor de catedra y su auxiliar discuten acerca la alineación (héroe o villano) del personaje de ficción Bat-Cow.

El cuerpo docente, no logra ponerse de acuerdo si el personaje es bueno, neutral o malo: el auxiliar plantea que Bat-cow posee una siniestra mirada, intrigante pero común característica de los personajes malvados. Por otra parte, extendiendo las ideas de Rousseau, el profesor plantea que tal como los humanos no nacen malos, no existe motivo por el cual una vaca con superpoderes deba serlo.

Sin embargo, ambos concuerdan que es difícil estimar la alineación solo usando los atributos físicos, por lo que creen el análisis debe ser complementado aún más antes de comunicarle los resultados a su estudiantado. Buscando más información, ambos sujetos se percatan de la existencia de un excelente antecedente para estimar la alineación: la historia personal de cada superhéroe o villano.

Es por esto le solicitan que construya y optimice un clasificador basado en texto el cual analice la alineación de cada personaje basado en su historia personal.

Para este laboratorio deben trabajar con los datos df_comics.csv y comics_no_label.csv subidos a u-cursos. El primero es un conjunto de datos que les servirá para entrenar un modelo de clasificación, mientras que el segundo es un dataset con personajes de ficción no etiquetados a predecir (sí, aquí está la misteriosa Batcow).

Para comenzar cargue los dataset señalados y visualice a través de un head los atributos que poseen cada uno de los dataset.

In [3]:
df_comics = pd.read_csv('data/df_comics.csv')
df_comics_no_label = pd.read_csv('data/comics_no_label.csv')
df_comics = df_comics.dropna(subset=['history_text']) # eliminar ejemplos sin historia
In [4]:
# queda a labor de su equipo hacer el análisis exploratorio
df_comics.head()
Out[4]:
Unnamed: 0 name real_name full_name overall_score history_text powers_text intelligence_score strength_score speed_score ... has_flight has_accelerated_healing has_weapons_master has_intelligence has_reflexes has_super_speed has_durability has_stamina has_agility has_super_strength
0 0 3-D Man Delroy Garrett, Jr. Delroy Garrett, Jr. 6 Delroy Garrett, Jr. grew up to become a track ... NaN 85 30 60 ... 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 1.0
1 2 A-Bomb Richard Milhouse Jones Richard Milhouse Jones 20 Richard "Rick" Jones was orphaned at a young ... On rare occasions, and through unusual circu... 80 100 80 ... 0.0 1.0 0.0 0.0 1.0 1.0 1.0 1.0 1.0 1.0
2 3 Aa Aa NaN 12 Aa is one of the more passive members of the P... NaN 80 50 55 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
3 4 Aaron Cash Aaron Cash Aaron Cash 5 Aaron Cash is the head of security at Arkham A... NaN 80 10 25 ... 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
4 5 Aayla Secura Aayla Secura NaN 8 ayla Secura was a Rutian Twi'lek Jedi Knight (... NaN 90 40 45 ... 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0

5 rows × 82 columns

Haremos un poco de exploración simple. Primero eliminemos el índice

In [5]:
exploration_df = df_comics.drop("Unnamed: 0", axis=1)  # Este df lo modificaremos libremente en exploración

Veamos la distribución de las categorías

In [6]:
fig = px.bar(exploration_df.alignment.value_counts())
fig.update_layout(xaxis_title="Alignment",
                  yaxis_title="Count",
                  title="Amount of data per alignment class")
fig

Observamos que el problema de clasificación está desbalanceado. Ahora veamos las features, empezando por las enteras:

In [7]:
exploration_df.select_dtypes("int")
Out[7]:
intelligence_score strength_score speed_score durability_score power_score combat_score
0 85 30 60 60 40 70
1 80 100 80 100 100 80
2 80 50 55 45 100 55
3 80 10 25 40 30 50
4 90 40 45 55 55 85
... ... ... ... ... ... ...
1362 90 10 25 30 100 55
1363 80 100 100 100 100 80
1364 95 50 100 75 100 80
1365 75 10 100 30 100 30
1366 45 80 75 95 80 50

1285 rows × 6 columns

Estos son precisamente los atributos de interés que aparecerán luego. Veamos sus distribuciones

In [8]:
exploration_df.select_dtypes("int").boxplot()

Agrupando ahora por la variable de respuesta

In [9]:
px.box(exploration_df.select_dtypes("int"), color=exploration_df.alignment)

No se ven patrones que permitan una clasificación trivial. Ahora se ven los objetos.

In [10]:
exploration_df.select_dtypes("object").nunique().sort_values(ascending=False)
Out[10]:
name                1285
history_text        1274
img                 1215
superpowers         1166
first_appearance    1003
real_name            971
powers_text          939
aliases              860
full_name            794
relatives            775
occupation           753
base                 519
alter_egos           463
place_of_birth       422
teams                390
weight               242
height               116
overall_score         87
type_race             63
creator               39
hair_color            30
eye_color             25
skin_color            15
alignment              3
gender                 2
dtype: int64

Es extraño que el nombre real no sea único: Esto puede ocurrir porque existen varias variantes del mismo personaje (distintas historias). Esto puede causar un sesgo en validación si es que un mismo personaje queda con distintas variantes en distintos splits (dado que en general seguiran alineados de la misma forma), pero por simplicidad ignoraremos esto.

Había realizado más análisis de otras características, pero decidí eliminarlo dado que nos concentraremos solamente en estas durante el laboratorio.

1.1 Obtención de Features y Bag of Words¶

Primero que todo, deben obtener un vector de características del atributo history_text, utilizando Bag of Words. En este atributo se presenta una breve descripción de la historia de cada uno de los personajes de ficción presentes en el dataset.

Pero... antes de empezar, ¿Que es Bag of Words?...

Bag of Words es un modelo de conteo utilizado en Procesamiento de Lenguaje Natural (NLP) que tiene como objetivo generar una representación vectorial (vector de características en nuestro cas) para cada documento a través del conteo de las palabras que contienen.

La siguiente figura muestra un ejemplo de Bag of Words en acción:

Como pueden ver, el modelo de Bag of Words no resulta tan complicado, ¿pero cómo lo aplicamos en python?.

Como podrán darse cuenta del ejemplo anterior, para facilitar el conteo será necesario transformar cada uno de los documentos en vectores, donde cada una de las posiciones posee un carácter. Este proceso es conocido como tokenización y lo podemos realizar de la siguiente forma:

In [11]:
docs = ['The teacher rocks like a good rock & roll',
             'the rock is the best actor in the world']


docs_tokenizados = [word_tokenize(doc)  for doc in docs]
docs_tokenizados
Out[11]:
[['The', 'teacher', 'rocks', 'like', 'a', 'good', 'rock', '&', 'roll'],
 ['the', 'rock', 'is', 'the', 'best', 'actor', 'in', 'the', 'world']]

Podemos mejorar un poco más el proceso de tokenización agregando

  • Stemming: Definimos Stemming como un algoritmo basado en reglas que transforma las palabras a una forma general. Un ejemplo de stemming, es el siguiente:
  • Eliminación de Stopwords: Eliminación de palabras muy frecuentes que entorpecen la clasificación (por ejemplo, el, la los, la, etc...)

In [12]:
stop_words = stopwords.words('english')  # stopwords.words('spanish')

# Definimos un tokenizador con Stemming
class StemmerTokenizer:
    def __init__(self):
        self.ps = PorterStemmer()

    def __repr__(self):
        return f"{self.__class__.__name__}()"

    def __call__(self, doc):
        doc_tok = word_tokenize(doc)
        doc_tok = [t for t in doc_tok if t not in stop_words]
        return [self.ps.stem(t) for t in doc_tok]

# Inicializamos tokenizador
tokenizador = StemmerTokenizer()

# Creamos algunos documentos
docs = ['The teacher rocks like a good rock & roll',
        'the rock is the best actor in the world',
        'New York is a beautiful city']

# Obtenemos el token del primer documento
[tokenizador(doc) for doc in docs]
Out[12]:
[['the', 'teacher', 'rock', 'like', 'good', 'rock', '&', 'roll'],
 ['rock', 'best', 'actor', 'world'],
 ['new', 'york', 'beauti', 'citi']]
In [13]:
# Comparación con el caso anterior
docs_tokenizados = [word_tokenize(doc) for doc in docs]
docs_tokenizados
Out[13]:
[['The', 'teacher', 'rocks', 'like', 'a', 'good', 'rock', '&', 'roll'],
 ['the', 'rock', 'is', 'the', 'best', 'actor', 'in', 'the', 'world'],
 ['New', 'York', 'is', 'a', 'beautiful', 'city']]

Al Estilo Scikit¶

Scikit implementa bag of words a través de la clase CountVectorizer() la cual contiene muchas opciones para mejorar la tokenización.

In [14]:
bow = CountVectorizer(tokenizer=StemmerTokenizer())
df = bow.fit_transform(docs)

pd.DataFrame(df.toarray(), columns=bow.get_feature_names_out())
Out[14]:
& actor beauti best citi good like new rock roll teacher world york
0 1 0 0 0 0 1 1 0 2 1 1 0 0
1 0 1 0 1 0 0 0 0 1 0 0 1 0
2 0 0 1 0 1 0 0 1 0 0 0 0 1

Una de las cosas más interesantes que provee son el use de n-gramas, los cuales, en palabras simples, son conjuntos de n-palabras que se concatenan entre si y que se consideran como tokens separados.

Pensemos en Nueva York. Cuando se tokeniza Nueva York, se generan dos tokens independientes que a simple vista no tienen relación: Nueva York. Al usar n-gramas (en un rango min=1,max=2) , generamos tanto Nueva y York como también Nueva York como un token independiente.

In [15]:
bow = CountVectorizer(tokenizer=StemmerTokenizer(), ngram_range=(1,2))
df = bow.fit_transform(docs)

pd.DataFrame(df.toarray(), columns=bow.get_feature_names_out())
Out[15]:
& & roll actor actor world beauti beauti citi best best actor citi good ... rock rock & rock best rock like roll teacher teacher rock world york york beauti
0 1 1 0 0 0 0 0 0 0 1 ... 2 1 0 1 1 1 1 0 0 0
1 0 0 1 1 0 0 1 1 0 0 ... 1 0 1 0 0 0 0 1 0 0
2 0 0 0 0 1 1 0 0 1 0 ... 0 0 0 0 0 0 0 0 1 1

3 rows × 25 columns

De los resultados, podemos ver que generamos vectores de conteo para cada una de las palabras que conforman el corpus. Un punto extra que se agrega en esta obtención de frecuencias son los bigramas, que básicamente son el conjunto de palabras de tamaño de aparecen juntas en el texto.

Codificando los Super{heroes, villanos} [0.5 Puntos]¶

Conociendo ahora que es el proceso de bag of words, aplique este modelo de obtención de caracteristicas de la siguiente forma en un pipeline:

  • Utilice el tokenizador entregado.
  • Obtenga caracteristicas de los unigramas y bigramas del texto (tal como el ejemplo).
bog = CountVectorizer(tokenizer= StemmerTokenizer(),`
                      ngram_range=(1,2) # Este punto es opcional y es para generar bigramas
                      )

Finalmente, aplique MinMaxScaler() sobre atributos_de_interes y concatene el valor obtenido con el matriz de caracteristicas obtenidas con bag of words.

atributos_de_interes = ['intelligence_score', 'strength_score', 'speed_score', 'durability_score', 'power_score', 'combat_score']

No es necesario que obtenga un dataframe en concreto con las características solicitadas. Se le recomienda generar un ColumnTransformer() para aplicar las transformaciones solicitadas en un pipeline.

To-Do:

  • [x] Obtener a traves de Bag of Words (CountVectorizer) caracteristicas del resumen de historia de cada personaje.
  • [x] Aplicar MinMaxScaler sobre los atributos de interes.

Respuesta:

In [16]:
atributos_de_interes = ['intelligence_score', 'strength_score', 'speed_score',
                        'durability_score', 'power_score', 'combat_score']
transformer = ColumnTransformer([("score_scaler", MinMaxScaler(), atributos_de_interes),
                                 ("bag_of_words", CountVectorizer(tokenizer=StemmerTokenizer(), ngram_range=(1,2)), "history_text"),
                                ]
                               )

De todos modos vale la pena ver el dataframe en concreto resultante

1.2 Diseño de Baseline y Primer Entrenamiento [1 Puntos]¶

Genere un Pipeline con las caracteristicas solicitadas en la sección 1.1, un selector de mejores features SelectPercentile con métrica f_classif y percentile=90 y un clasificador MultinomialNB() por defecto.

Luego, separe el conjunto de datos en un conjunto de entrenamiento y prueba, donde las etiquetas estará dado por el atributo alignment.

Entrene el modelo y reporte el desempeño con un classification_report. ¿ Nos recomendaría predecir la alineación de BatCow con este clasificador?.

Finalmente, compare el modelo entrenado con un modelo Dummy estratificado y responda: ¿El clasificador entrenado es mejor que el dummy que entrega respuestas al azar?

To-do:

  • [x] Realizar un pipeline con las caracteristicas solicitadas en 1.1, ejecutar holdout y aplicar un clasificador MultinomialNB().
  • [x] Entrenar el pipeline, calcular el classification_report asociado y comentar los resultados.
  • [x] Entrenar un DummyClassifier con estrategia statified, calcular el classification_report asociado y comentar que implican los scores obtenidos en comparación con los resultados del baseline.

Respuesta:

Creando el Pipeline

In [17]:
feature_pipeline = make_pipeline(transformer,
                                 SelectPercentile(percentile=90)  # default ya es f_classif
                                )
nb_clf = make_pipeline(feature_pipeline,
                       MultinomialNB(alpha=.5)
                      )

Preparando los datos

In [18]:
X = df_comics.drop("alignment", axis=1)
y = df_comics.alignment

Dado el desbalance ya determinado, realizamos el holdout con estratificación.

In [19]:
X_train, X_test, y_train, y_test = train_test_split(X, y, random_state=0,
                                                    stratify=y, shuffle=True)

y_testEntrenando el pipeline, y ver el classification_report

In [20]:
y_pred = nb_clf.fit(X_train, y_train).predict(X_test)
print(classification_report(y_test, y_pred))
              precision    recall  f1-score   support

         Bad       0.68      0.25      0.36       108
        Good       0.63      0.94      0.76       186
     Neutral       0.17      0.04      0.06        28

    accuracy                           0.63       322
   macro avg       0.49      0.41      0.39       322
weighted avg       0.61      0.63      0.57       322

Analizando el desempeño según cada clase:

  • Buenos: Alto desempeño tanto en precisión como recall, se discrimina cuando alguien es bueno o no. El recall es más alto que la precisión, así que tal vez se subestima la cantidad de buenos.
  • Malos: Acá vemos mejor precisión que recall, en contraste al caso anterior, subestima la cantidad de malos, pero en una proporción mucho mayor,
  • Neutros: Esta es la clase minoritaria, y es donde vemos peores puntajes, con un pésimo f1-score.
In [21]:
y_pred = DummyClassifier(strategy="stratified", random_state=0).fit(X_train, y_train).predict(X_test)
print(classification_report(y_test, y_pred))
              precision    recall  f1-score   support

         Bad       0.32      0.30      0.31       108
        Good       0.58      0.60      0.59       186
     Neutral       0.09      0.11      0.10        28

    accuracy                           0.45       322
   macro avg       0.33      0.33      0.33       322
weighted avg       0.45      0.45      0.45       322

Acá vemos que el desempeño de clasificación del Dummy es peor para los buenos y los malos, tal como se espera, pero vemos que MultinomialNB es peor en clasificar a los personajes neutrales que una estrategia aleatoria. Esto ocurre por el gran desbalance que desfavorece al clasificador, por lo cual preferiremos un clasificador como Complement Naive Bayes

1.3 Busqueda del Mejor Modelo con Grid Search [4 Puntos]¶

No conformes con el rendimiento obtenido en la sección 1.2, el cuerpo docente les pide que realicen un HalvingGridSearchCV con diferentes parámetros para mejorar el rendimiento de la clasificación. Para esto, se le solicita que defina:

  • Tres clasificadores distintos en donde varie sus parámetros. Considere usar modelos clásicos como también los basados en ensamblaje.
  • Modificar n-gram range del CountVectorizer probando (1,1), (1,2) y (1,3). Examinar también los otros parámetros de CountVectorizer como por ejemplo max_df, min_df, etc... (Documentación aquí)
  • Seleccionar las columnas que contribuyen con la mayor información para la clasificación con SelectPercentile en los percentiles [20, 40, 60, 80] (puede usar la métrica que usted quiera).
  • Reporte la mejor combinación encontrada y justifique por qué cree que es la mejor según el clasificador usado, la cantidad de columnas seleccionadas y los parámetros de CountVectorizer seleccionados por GridSearch.

A continuación, un ejemplo de parametros para GridSearch para una búsqueda de 3 clasificadores distintos:

params = [
       # clasificador 1 + hiperparámetros
       {'clf': classificator1(),
        'clf__penalty': ['ovr'],
       # clasificador 1 + hiperparámetros    
       {'clf': classificator2(),
        'clf__n_estimators': [200]},
       # clasificador 1 + hiperparámetros
       {'clf': classificator3(),
        ...
       }
       ]

Nota 1: Puede ver los parámetros modificables aplicando el método get_params() sobre su pipeline. Ver la clase de GridSearch para mayor información sobre la sintáxis de las grillas.

Nota 2: Recuerde inicializar los clasificadores con un random state definido.

Nota 3: Puede usar en HalvingGridSearchCV el parámetro verbose=10 para ver que GridSearch le indique el estado de su ejecución.

Nota 3: El GridSearch puede tomar tiempos de búsqueda exorbitantes, por lo que se le recomienda no agrandar mucho el espacio de búsqueda, dejar corriendo el código y tomarse un tecito.

Respuesta:

Los modelos a elegir se basarán en la guía de sklearn de elección de modelos, que recomienda SVC lineal, Naïve Bayes, SVC rbf y modelos de ensamblaje. En particular, para la búsqueda de hiperparámetros consideramos:

  • SVM tanto con kernel lineal como rbf. Se usa escalamiento MaxAbsScaler, pues toda variable ya es no negativa y así no se rompe sparsity. El otro hiperparámetro a modificar es la regularización, como se recomienda en la guía de uso
  • ComplementNB, que es la variante de NB recomendada para problemas con desbalance. Los únicos hiperparámetros relevantes son la suavización de Lidstone y si se normalizan las frecuencias.
  • ExtraTreesClassifier como modelo de ensamblaje de árboles, pues este tiene menor varianza que RandomTreeClassifier al costo de mayor sesgo. Según la guía de parámetros, se modifica la cantidad de características vistas. Otros parámetros afectan principalmente a la velocidad de entrenamiento, así que se modifica también la cantidad de los datos muestreados por árbol pues esto afecta al balance sesgo-varianza.

En términos del problema a resolver, no tenemos manera fácil de decidir cuál tipo de error es el más grave. Si trabajáramos para superhéroes, entonces no quisieramos que personajes son buenos cuando en realidad son malos, pero podríamos estar trabajando para supervillanos así que no haré esos supuestos, y se hará la búsqueda según f1_weighted, pero veremos los classification_scores para elección.

Cambiamos el score_func de SelectPercentile a chi2 para que vea dependencias no lineales, de manera mucho más veloz que mutual_info_classif, y aprovechando que las variables ya son no negativas.

In [22]:
feature_params = {"pipeline__columntransformer__bag_of_words__ngram_range": [(1, 1), (1, 2), (1, 3)],
                  "pipeline__columntransformer__bag_of_words__binary": [False, True],
                  "pipeline__selectpercentile__percentile": [20, 40, 60, 80],
                  "pipeline__selectpercentile__score_func": [chi2],
                 }
In [23]:
def grid_search_eval(model, model_params):
    """Calcula el mejor modelo con gridsearch, imprime su reporte de clasificación,
    retorna el modelo y sus parámetros."""
    pipeline = Pipeline(steps=[("pipeline", feature_pipeline), ("model", model)])
    model_params = {f"model__{key}": val for key, val in model_params.items()}
    grid = HalvingGridSearchCV(pipeline, feature_params | model_params,
                               cv=3, scoring="f1_weighted", random_state=0,
                               n_jobs=-1, min_resources="smallest", aggressive_elimination=True,
                               verbose=10,
                              )
    best_model = grid.fit(X_train, y_train).best_estimator_
    print(classification_report(y_test, best_model.predict(X_test)))
    return best_model, grid.best_params_
In [24]:
cnb, cnb_params = grid_search_eval(ComplementNB(),
                                   {"alpha": [0.5, 1.],
                                    "norm": [True, False]
                                   }
                                  )
n_iterations: 5
n_required_iterations: 5
n_possible_iterations: 4
min_resources_: 18
max_resources_: 963
aggressive_elimination: True
factor: 3
----------
iter: 0
n_candidates: 96
n_resources: 18
Fitting 3 folds for each of 96 candidates, totalling 288 fits
----------
iter: 1
n_candidates: 32
n_resources: 18
Fitting 3 folds for each of 32 candidates, totalling 96 fits
----------
iter: 2
n_candidates: 11
n_resources: 54
Fitting 3 folds for each of 11 candidates, totalling 33 fits
----------
iter: 3
n_candidates: 4
n_resources: 162
Fitting 3 folds for each of 4 candidates, totalling 12 fits
----------
iter: 4
n_candidates: 2
n_resources: 486
Fitting 3 folds for each of 2 candidates, totalling 6 fits
              precision    recall  f1-score   support

         Bad       0.55      0.54      0.54       108
        Good       0.72      0.76      0.74       186
     Neutral       0.30      0.21      0.25        28

    accuracy                           0.64       322
   macro avg       0.52      0.50      0.51       322
weighted avg       0.63      0.64      0.63       322

En este punto, vemos que sí este clasificador le gana al Dummy en todo aspecto, pero existe un trade-off con respecto a MultinomialNB, donde se gana mejores puntajes para discriminar a personajes neutrales y malos, al costo de puntaje para personajes buenos. Sin embargo, los puntajes en general mejoran bastante, en especial en la clase Neutral.

In [25]:
rescale_svc = make_pipeline(MaxAbsScaler(),
                            SVC(random_state=0,
                                class_weight="balanced",
                               )
                           )
svc, svc_params = grid_search_eval(rescale_svc,
                                   {"svc__kernel": ["linear", "rbf"],
                                    "svc__C": [.5, 1.]
                                   }
                                  )
n_iterations: 5
n_required_iterations: 5
n_possible_iterations: 4
min_resources_: 18
max_resources_: 963
aggressive_elimination: True
factor: 3
----------
iter: 0
n_candidates: 96
n_resources: 18
Fitting 3 folds for each of 96 candidates, totalling 288 fits
----------
iter: 1
n_candidates: 32
n_resources: 18
Fitting 3 folds for each of 32 candidates, totalling 96 fits
----------
iter: 2
n_candidates: 11
n_resources: 54
Fitting 3 folds for each of 11 candidates, totalling 33 fits
----------
iter: 3
n_candidates: 4
n_resources: 162
Fitting 3 folds for each of 4 candidates, totalling 12 fits
----------
iter: 4
n_candidates: 2
n_resources: 486
Fitting 3 folds for each of 2 candidates, totalling 6 fits
              precision    recall  f1-score   support

         Bad       0.61      0.39      0.47       108
        Good       0.67      0.89      0.76       186
     Neutral       0.17      0.04      0.06        28

    accuracy                           0.65       322
   macro avg       0.48      0.44      0.43       322
weighted avg       0.60      0.65      0.60       322

Acá vemos que se empeora el f1-score tanto en los malos como los buenos, ligeramente empeorando el modelo con respecto a ComplementNB, pero además este modelo no lidia bien con el desbalance, sin determinar correctamente cuando son neutrales. Su desempeño general aún así es mejor que el de MultinomialNB.

In [26]:
trees, tree_params = grid_search_eval(ExtraTreesClassifier(random_state=0,
                                                           class_weight="balanced_subsample",
                                                           bootstrap=True,
                                                          ),
                                      {"max_features": ["sqrt", None],
                                       "max_samples": [.75, 1.]
                                      }
                                     )
n_iterations: 5
n_required_iterations: 5
n_possible_iterations: 4
min_resources_: 18
max_resources_: 963
aggressive_elimination: True
factor: 3
----------
iter: 0
n_candidates: 96
n_resources: 18
Fitting 3 folds for each of 96 candidates, totalling 288 fits
----------
iter: 1
n_candidates: 32
n_resources: 18
Fitting 3 folds for each of 32 candidates, totalling 96 fits
----------
iter: 2
n_candidates: 11
n_resources: 54
Fitting 3 folds for each of 11 candidates, totalling 33 fits
----------
iter: 3
n_candidates: 4
n_resources: 162
Fitting 3 folds for each of 4 candidates, totalling 12 fits
----------
iter: 4
n_candidates: 2
n_resources: 486
Fitting 3 folds for each of 2 candidates, totalling 6 fits
              precision    recall  f1-score   support

         Bad       0.62      0.42      0.50       108
        Good       0.68      0.91      0.78       186
     Neutral       0.00      0.00      0.00        28

    accuracy                           0.66       322
   macro avg       0.43      0.44      0.43       322
weighted avg       0.60      0.66      0.62       322

Este clasificador nunca predijo que uno fuera Neutral, así que no ldiio bien con el desbalance. Si bien es mejor en discriminar a los buenos, el desempeño general sigue siendo ligeramente peor que ComplementNB

Nos quedamos con ComplementNB por tener un poco más de desempeño en mucho menos tiempo de entrenamiento. sklearn lo recomienda en la guía de Naïve Bayes, y tuvo efectivamente el mejor desempeño. Veamos los parámetros que fueron elegidos.

In [27]:
cnb_params
Out[27]:
{'model__alpha': 0.5,
 'model__norm': False,
 'pipeline__columntransformer__bag_of_words__binary': False,
 'pipeline__columntransformer__bag_of_words__ngram_range': (1, 1),
 'pipeline__selectpercentile__percentile': 80,
 'pipeline__selectpercentile__score_func': <function sklearn.feature_selection._univariate_selection.chi2(X, y)>}
  • Se utilizaron solamente 1-gramas
  • Se codificó la cantidad de veces que se usó la palabra, en vez de usar codificación binaria
  • Se seleccionaron 80% de las características
  • Para Naive Bayes, se usó un factor de suavización de 0.5, y no se usó una segunda normalización

Veamos las características más importantes según este modelo.

In [28]:
cnb.fit(X, y)  # Re entrenando con todos los datos
final_feature_transformer = cnb.named_steps["pipeline"]
final_features = final_feature_transformer.transform(X)

pd.DataFrame(final_features.toarray(), columns=final_feature_transformer.get_feature_names_out())
Out[28]:
score_scaler__strength_score score_scaler__speed_score score_scaler__durability_score score_scaler__power_score score_scaler__combat_score bag_of_words__! bag_of_words__# bag_of_words__$ bag_of_words__& bag_of_words__' ... bag_of_words__— bag_of_words__‘ bag_of_words__’ bag_of_words__“ bag_of_words__” bag_of_words__アーカード bag_of_words__駄犬 bag_of_words__juggernaut bag_of_words__� bag_of_words__�kick�
0 0.3 0.60 0.60 0.40 0.70 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1 1.0 0.80 1.00 1.00 0.80 0.0 0.0 0.0 0.0 2.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
2 0.5 0.55 0.45 1.00 0.55 0.0 1.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
3 0.1 0.25 0.40 0.30 0.50 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
4 0.4 0.45 0.55 0.55 0.85 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...
1280 0.1 0.25 0.30 1.00 0.55 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1281 1.0 1.00 1.00 1.00 0.80 1.0 0.0 0.0 0.0 8.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1282 0.5 1.00 0.75 1.00 0.80 0.0 0.0 0.0 0.0 0.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
1283 0.1 1.00 0.30 1.00 0.30 0.0 0.0 0.0 0.0 1.0 ... 0.0 0.0 1.0 1.0 1.0 0.0 0.0 0.0 0.0 0.0
1284 0.8 0.75 0.95 0.80 0.50 0.0 0.0 0.0 0.0 2.0 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0

1285 rows × 18229 columns

In [29]:
importance_frame = pd.DataFrame(index=final_feature_transformer.get_feature_names_out())
for alignment, importance in zip(cnb.named_steps['model'].classes_, cnb.named_steps['model'].feature_log_prob_):
    importance_frame[alignment] = importance #pd.Series(importance, )
importance_frame
Out[29]:
Bad Good Neutral
score_scaler__strength_score 7.288231 6.691373 7.125317
score_scaler__speed_score 7.068273 6.593954 6.960767
score_scaler__durability_score 6.898151 6.379045 6.764895
score_scaler__power_score 6.709533 6.216074 6.583681
score_scaler__combat_score 6.671396 6.230883 6.563095
... ... ... ...
bag_of_words__アーカード 13.732044 11.762163 12.820508
bag_of_words__駄犬 13.732044 11.251337 12.309682
bag_of_words__juggernaut 13.732044 11.762163 12.820508
bag_of_words__� 9.441585 9.147203 9.628660
bag_of_words__�kick� 12.122606 12.860775 12.309682

18229 rows × 3 columns

Veamos las más altas de cada clase

In [30]:
importance_frame[importance_frame.Bad == importance_frame.Bad.max()]
Out[30]:
Bad Good Neutral
bag_of_words__'29 13.732044 11.762163 12.820508
bag_of_words__'a 13.732044 11.762163 12.820508
bag_of_words__'autopsi 13.732044 11.762163 12.820508
bag_of_words__'birth 13.732044 11.762163 12.820508
bag_of_words__'bloodtox 13.732044 11.762163 12.820508
... ... ... ...
bag_of_words__ε 13.732044 11.762163 12.820508
bag_of_words__تاليا 13.732044 11.762163 12.820508
bag_of_words__アーカード 13.732044 11.762163 12.820508
bag_of_words__駄犬 13.732044 11.251337 12.309682
bag_of_words__juggernaut 13.732044 11.762163 12.820508

2819 rows × 3 columns

In [31]:
importance_frame[importance_frame.Good == importance_frame.Good.max()]
Out[31]:
Bad Good Neutral
bag_of_words__'after 12.633432 12.860775 12.820508
bag_of_words__'alpha 12.633432 12.860775 12.820508
bag_of_words__'arm 12.633432 12.860775 12.820508
bag_of_words__'arti 12.633432 12.860775 12.820508
bag_of_words__'bad 12.633432 12.860775 12.820508
... ... ... ...
bag_of_words__· 11.534819 12.860775 11.721895
bag_of_words__ñoldor 9.688993 12.860775 9.876069
bag_of_words__σ 12.122606 12.860775 12.309682
bag_of_words__نيسا 12.122606 12.860775 12.309682
bag_of_words__�kick� 12.122606 12.860775 12.309682

7397 rows × 3 columns

In [32]:
importance_frame[importance_frame.Neutral == importance_frame.Neutral.max()]
Out[32]:
Bad Good Neutral
bag_of_words__'convinc 12.633432 11.762163 13.91912
bag_of_words__'last 12.633432 11.762163 13.91912
bag_of_words__'puppet 12.633432 11.762163 13.91912
bag_of_words__'re-educ 12.633432 11.762163 13.91912
bag_of_words__'replac 12.633432 11.762163 13.91912
... ... ... ...
bag_of_words__zoiray 11.534819 10.663550 13.91912
bag_of_words__الساحر 12.633432 11.762163 13.91912
bag_of_words__الغول‎ 12.633432 11.762163 13.91912
bag_of_words__سراب 12.633432 11.762163 13.91912
bag_of_words__​​ 12.122606 11.251337 13.91912

874 rows × 3 columns

1.4 Predicción del datos sin etiquetado [0.5 puntos]¶

LLego el momento de predecir Vergil, Gorilla Girl y Batcow

Nota: Recuerde que pueden existir campos vacios en history_text, por lo que se les recomienda borrar los nan.

Respuesta:

In [33]:
to_predict = df_comics_no_label.dropna(subset="history_text")
to_predict = to_predict[to_predict.name.isin({"Vergil", "Gorilla Girl", "Batcow"})].drop_duplicates()
to_predict
Out[33]:
Unnamed: 0 name real_name full_name overall_score history_text powers_text intelligence_score strength_score speed_score ... has_flight has_accelerated_healing has_weapons_master has_intelligence has_reflexes has_super_speed has_durability has_stamina has_agility has_super_strength
16 122 Batcow None None 3 Bat-Cow was originally a cow that was found by... NaN 70 10 25 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
40 529 Gorilla Girl Fahnbullah Eddy Fahnbullah Eddy 7 A carnival performer with the ability to turn ... Gorilla Girl can transform into a talking gori... 90 35 60 ... 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 1.0
78 1368 Vergil Vergil Sparda NaN 16 Vergil, later also known as Nelo Angelo, is on... NaN 90 75 95 ... 0.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0 1.0

3 rows × 82 columns

Para esta sección, reentrenamos el modelo en todos los datos

In [34]:
predictions = cnb.predict(to_predict)
pd.Series(predictions, index=to_predict.name)
Out[34]:
name
Batcow             Good
Gorilla Girl        Bad
Vergil          Neutral
dtype: object
  • Cómo el fan Nº1 de BatCow, puedo confirmar que es buena
  • Gorilla girl es heroina, así que el clasificador se equivocó
  • Vergil es antagonista, ocasionalmente cómo anti villano. De lo que investigué, su estatus moral es debatible, de forma que probablemente debería ser malo, pero neutral es aceptable.

Aprovecho de incluir mi tier list de los mejores candidatos para reemplazar a Batman en el caso de su muerte, con BatCow sólidamente en A tier. ¿El mejor? El mismísimo Batman.

my-image.png

Created in deepnote.com Created in Deepnote