Capítulo 4. Hacer predicciones con árboles de decisióny bosques de decisión

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

La clasificación y la regresión son los tipos de análisis predictivo más antiguos y más estudiados. La mayoría de los algoritmos que encontrarás en paquetes y bibliotecas de análisis son técnicas de clasificación o regresión, como las máquinas de vectores soporte, la regresión logística, las redes neuronales y el aprendizaje profundo. El hilo común que une la regresión y la clasificación es que ambas implican la predicción de uno (o más) valores dados uno (o más) otros valores. Para ello, ambas requieren un conjunto de entradas y salidas de las que aprender. Necesitan recibir preguntas y respuestas conocidas. Por eso se conocen como tipos de aprendizaje supervisado.

PySpark MLlib ofrece implementaciones de un número de algoritmos de clasificación y regresión. Entre ellos están los árboles de decisión, Bayes ingenuo, regresión logística y regresión lineal. Lo interesante de estos algoritmos es que pueden ayudar a predecir el futuro o, al menos, a predecir las cosas que aún no sabemos con certeza, como la probabilidad de que compres un coche según tu comportamiento en Internet, si un correo electrónico es spam por las palabras que contiene, o qué hectáreas de tierra tienen más probabilidades de producir más cultivos según su ubicación y la química del suelo.

En este capítulo, nos centraremos en un tipo de algoritmo popular y flexible para tanto la clasificación como la regresión (árboles de decisión) y la extensión del algoritmo (bosques de decisión aleatorios). En primer lugar, comprenderemos los fundamentos de los árboles y bosques de decisión e introduciremos la implementación PySpark de los primeros. La implementación PySpark de los árboles de decisión admite la clasificación binaria y multiclase, y la regresión. La implementación particiona los datos por filas, lo que permite un entrenamiento distribuido con millones o incluso miles de millones de instancias. A continuación, prepararemos nuestro conjunto de datos y crearemos nuestro primer árbol de decisión. Luego afinaremos nuestro modelo de árbol de decisión. Terminaremos entrenando un modelo de bosque aleatorio en nuestro conjunto de datos procesado y haciendo predicciones.

Aunque la implementación del árbol de decisión de PySpark es fácil para empezar, es útil entender los fundamentos de los algoritmos de árbol de decisión y bosque aleatorio. Eso es lo que cubriremos en la siguiente sección.

Árboles y bosques de decisión

Los árboles de decisión son una familia de algoritmos que pueden manejar de forma natural características tanto categóricas como numéricas. La construcción de un único árbol puede realizarse mediante computación paralela, y pueden construirse muchos árboles a la vez en paralelo. Son resistentes a los valores atípicos de los datos, lo que significa que unos pocos puntos de datos extremos y posiblemente erróneos pueden no afectar en absoluto a las predicciones. Pueden consumir datos de distintos tipos y a distintas escalas sin necesidad de preprocesamiento o normalización.

Los algoritmos basados en árboles de decisión tienen la ventaja de ser comparativamente intuitivos de entender y razonar. De hecho, probablemente todos utilicemos el mismo razonamiento plasmado en árboles de decisión, de forma implícita, en la vida cotidiana. Por ejemplo, me siento a tomar el café de la mañana con leche. Antes de comprometerme con esa leche y añadirla a mi infusión, quiero predecir: ¿está estropeada la leche? No lo sé con seguridad. Podría comprobar si ha pasado la fecha de caducidad. Si no es así, predigo que no, que no está estropeada. Si la fecha ha pasado, pero hace tres días o menos, me arriesgo y predigo que no, que no está estropeada. Si no, huelo la leche. Si huele raro, predigo que sí, y si no, que no.

Esta serie de decisiones sí/no que conducen a una predicción es lo que encarnan los árboles de decisión. Cada decisión conduce a uno de dos resultados, que es una predicción u otra decisión, como se muestra en la Figura 4-1. En este sentido, es natural pensar en el proceso como un árbol de decisiones, donde cada nodo interno del árbol es una decisión, y cada nodo hoja es una respuesta final.

Ése es un árbol de decisión simplista y no se construyó con ningún rigor. Para elaborarlo, considera otro ejemplo. Un robot ha aceptado un trabajo en una tienda de animales exóticos. Quiere saber, antes de abrir la tienda, qué animales de la tienda serían una buena mascota para un niño. El dueño enumera nueve mascotas que serían y no serían adecuadas antes de salir corriendo. El robot recopila la información de la Tabla 4-1 a partir del examen de los animales.

aaps 0401
Figura 4-1. Árbol de decisión: ¿está estropeada la leche?
Tabla 4-1. "Vectores de características" de las tiendas de mascotas exóticas
Nombre Peso (kg) # Piernas Color ¿Buena mascota?

Fido

20.5

4

Marrón

Sr. Slither

3.1

0

Verde

No

Nemo

0.2

0

Tan

Dumbo

1390.8

4

Gris

No

Kitty

12.1

4

Gris

Jim

150.9

2

Tan

No

Millie

0.1

100

Marrón

No

McPigeon

1.0

2

Gris

No

Spot

10.0

4

Marrón

El robot puede tomar una decisión sobre las nueve mascotas de la lista. Hay muchas más mascotas disponibles en la tienda. Todavía necesita una metodología para decidir qué animales de entre el resto serán adecuados como mascotas para los niños. Podemos suponer que se dispone de las características de todos los animales. Utilizando los datos de decisión proporcionados por el dueño de la tienda y un árbol de decisión, podemos ayudar al robot a aprender cómo es una buena mascota para un niño.

Aunque se dé un nombre, no se incluirá como característica en nuestro modelo de árbol de decisión. Hay pocas razones para creer que el nombre por sí solo sea predictivo; "Félix" podría nombrar a un gato o a una tarántula venenosa, por lo que sabe el robot. Así pues, hay dos características numéricas (peso, número de patas) y una categórica (color) que predicen un objetivo categórico (es/no es una buena mascota para un niño.

La forma en que funciona un árbol de decisión es tomando una o más decisiones en secuencia, basándose en las características proporcionadas. Para empezar, el robot podría intentar ajustar un árbol de decisión simple a estos datos de entrenamiento, consistente en una única decisión basada en el peso, como se muestra en la Figura 4-2.

aaps 0402
Figura 4-2. Primer árbol de decisión del robot

La lógica del árbol de decisión es fácil de leer y de entender: Los animales de 500 kg parecen ciertamente inadecuados como animales de compañía. Esta regla predice el valor correcto en cinco de nueve casos. Un vistazo rápido sugiere que podríamos mejorar la regla bajando el umbral de peso a 100 kg. Esto consigue que seis de nueve ejemplos sean correctos. Los animales pesados se predicen ahora correctamente; los más ligeros, sólo parcialmente.

Por tanto, se puede construir una segunda decisión para afinar más la predicción para los ejemplos con pesos inferiores a 100 kg. Sería bueno elegir una característica que cambie algunas de las predicciones incorrectas de Sí a No. Por ejemplo, hay un pequeño animal verde, que suena sospechosamente como una serpiente, que será clasificado por nuestro modelo actual como candidato a mascota. El robot podría predecir correctamente añadiendo una decisión basada en el color, como se muestra en la Figura 4-3.

aaps 0403
Figura 4-3. Árbol de próximas decisiones del robot

Ahora, siete de nueve ejemplos son correctos. Por supuesto, se podrían añadir reglas de decisión hasta predecir correctamente los nueve. La lógica plasmada en el árbol de decisión resultante probablemente sonaría inverosímil al traducirla al habla común: "Si el animal pesa menos de 100 kg, su color es marrón en vez de verde y tiene menos de 10 patas, entonces sí, es una mascota adecuada". Aunque se ajusta perfectamente a los ejemplos dados, un árbol de decisión como éste no predeciría que un glotón pequeño, marrón y con cuatro patas no es una mascota adecuada. Se necesita cierto equilibrio para evitar este fenómeno, conocido como sobreajuste.

Los árboles de decisión se generalizan en un algoritmo más potente, llamado bosques aleatorios. Los bosques aleatorios combinan muchos árboles de decisión para reducir el riesgo de sobreajuste y entrenar los árboles de decisión por separado. El algoritmo inyecta aleatoriedad en el proceso de entrenamiento para que cada árbol de decisión sea un poco diferente. Combinar las predicciones reduce la varianza de las predicciones, hace que el modelo resultante sea más generalizable y mejora el rendimiento en los datos de prueba.

Esta es una introducción suficiente a los árboles de decisión y los bosques aleatorios para que podamos empezar a utilizarlos con PySpark. En la siguiente sección, presentaremos el conjunto de datos con el que trabajaremos y lo prepararemos para su uso en PySpark.

Preparación de los datos

El conjunto de datos utilizado en este capítulo es el bien conocido conjunto de datos Covtype, disponible en línea como archivo de datos comprimido en formato CSV, covtype.data.gz, y archivo de información adjunto, covtype.info.

El conjunto de datos registra los tipos de parcelas cubiertas de bosques en Colorado (EE.UU.). ¡Es sólo una coincidencia que el conjunto de datos se refiera a bosques del mundo real! Cada registro de datos contiene varias características que describen cada parcela de terreno -como su elevación, pendiente, distancia al agua, sombra y tipo de suelo- junto con el tipo de bosque conocido que cubre el terreno. El tipo de cubierta forestal debe predecirse a partir del resto de características, que son 54 en total.

Este conjunto de datos se ha utilizado en investigación e incluso un concurso Kaggle. Es un conjunto de datos interesante para explorar en este capítulo porque contiene características tanto categóricas como numéricas. Hay 581.012 ejemplos en el conjunto de datos, lo que no se puede calificar exactamente de big data, pero es lo suficientemente grande como para ser manejable como ejemplo y, aun así, poner de manifiesto algunos problemas de escala.

Afortunadamente, los datos ya están en un formato CSV sencillo y no requieren mucha limpieza u otra preparación para ser utilizados con PySpark MLlib. El archivo covtype. data debe extraerse y copiarse en tu almacenamiento local o en la nube (como AWS S3).

Inicia pyspark-shell. Puede que te resulte útil dar al intérprete de comandos una buena cantidad de memoria con la que trabajar, ya que la creación de bosques de decisión puede consumir muchos recursos. Si dispones de memoria, especifica --driver-memory 8g o similar.

Los archivos CSV contienen fundamentalmente datos tabulares, organizados en filas de columnas. A veces, estas columnas reciben nombres en una línea de encabezamiento, aunque no es el caso aquí. Los nombres de las columnas se dan en el archivo complementario, covtype.info. Conceptualmente, cada columna de un archivo CSV también tiene un tipo -un número, una cadena-, pero un archivo CSV no lo especifica.

Es natural analizar estos datos como un marco de datos, porque es la abstracción de PySpark para los datos tabulares, con un esquema de columnas definido, que incluye los nombres y tipos de las columnas. PySpark tiene soporte integrado para leer datos CSV. Leamos nuestro conjunto de datos como un DataFrame utilizando el lector CSV incorporado:

data_without_header = spark.read.option("inferSchema", True)\
                      .option("header", False).csv("data/covtype.data")
data_without_header.printSchema()
...
root
 |-- _c0: integer (nullable = true)
 |-- _c1: integer (nullable = true)
 |-- _c2: integer (nullable = true)
 |-- _c3: integer (nullable = true)
 |-- _c4: integer (nullable = true)
 |-- _c5: integer (nullable = true)
 ...

Este código lee la entrada como CSV y no intenta analizar la primera línea como una cabecera de nombres de columnas. También pide que se infiera el tipo de cada columna examinando los datos. Infiere correctamente que todas las columnas son números y, más concretamente, enteros. Por desgracia, sólo puede nombrar las columnas _c0 y así sucesivamente.

Podemos buscar en el archivo covtype. info los nombres de las columnas.

$ cat data/covtype.info

...
[...]
7.	Attribute information:

Given is the attribute name, attribute type, the measurement unit and
a brief description.  The forest cover type is the classification
problem.  The order of this listing corresponds to the order of
numerals along the rows of the database.

Name                                    Data Type
Elevation                               quantitative
Aspect                                  quantitative
Slope                                   quantitative
Horizontal_Distance_To_Hydrology        quantitative
Vertical_Distance_To_Hydrology          quantitative
Horizontal_Distance_To_Roadways         quantitative
Hillshade_9am                           quantitative
Hillshade_Noon                          quantitative
Hillshade_3pm                           quantitative
Horizontal_Distance_To_Fire_Points      quantitative
Wilderness_Area (4 binary columns)      qualitative
Soil_Type (40 binary columns)           qualitative
Cover_Type (7 types)                    integer

Measurement                  Description

meters                       Elevation in meters
azimuth                      Aspect in degrees azimuth
degrees                      Slope in degrees
meters                       Horz Dist to nearest surface water features
meters                       Vert Dist to nearest surface water features
meters                       Horz Dist to nearest roadway
0 to 255 index               Hillshade index at 9am, summer solstice
0 to 255 index               Hillshade index at noon, summer soltice
0 to 255 index               Hillshade index at 3pm, summer solstice
meters                       Horz Dist to nearest wildfire ignition point
0 (absence) or 1 (presence)  Wilderness area designation
0 (absence) or 1 (presence)  Soil Type designation
1 to 7                       Forest Cover Type designation
...

Si nos fijamos en la información de las columnas, está claro que algunas características son efectivamente numéricas. Elevation es una elevación en metros; Slope se mide en grados. Sin embargo, Wilderness_Area es algo diferente, porque se dice que abarca cuatro columnas, cada una de las cuales es un 0 o un 1. En realidad, Wilderness_Area es un valor categórico, no numérico.

Estas cuatro columnas son en realidad una codificación de un punto o 1-de-N. Cuando se realiza esta forma de codificación sobre un rasgo categórico, un rasgo categórico que toma N valores distintos se convierte en N rasgos numéricos, cada uno de los cuales toma el valor 0 ó 1. Exactamente uno de los N valores tiene valor 1 y los demás son 0. Exactamente uno de los N valores tiene valor 1, y los demás son 0. Por ejemplo, un rasgo categórico del tiempo que puede ser cloudy, rainy, o clear se convertiría en tres rasgos numéricos, donde cloudy se representa por 1,0,0; rainy por 0,1,0; y así sucesivamente. Estas tres características numéricas podrían considerarse is_cloudy, is_rainy y is_clear. Del mismo modo, 40 de las columnas son en realidad una característica categórica Soil_Type.

Ésta no es la única forma posible de codificar una característica categórica como un número. Otra codificación posible simplemente asigna un valor numérico distinto a cada valor posible de la característica categórica. Por ejemplo, cloudy puede convertirse en 1,0, rainy en 2,0, y así sucesivamente. El propio objetivo, Cover_Type, es un valor categórico codificado como un valor de 1 a 7.

Ten cuidado al codificar un rasgo categórico como un único rasgo numérico. Los valores categóricos originales no tienen orden, pero al codificarlos como un número, parece que sí lo tienen. Tratar la característica codificada como numérica conduce a resultados sin sentido, porque el algoritmo está fingiendo de hecho que rainy es de alguna manera mayor que, y dos veces mayor que, cloudy. No pasa nada mientras el valor numérico de la codificación no se utilice como un número.

Hemos visto ambos tipos de codificación de rasgos categóricos. Tal vez habría sido más sencillo y directo no codificar tales rasgos (y de dos formas, nada menos) y, en su lugar, limitarse a incluir directamente sus valores, como "Área silvestre de Rawah". Esto puede ser un artefacto de la historia; el conjunto de datos se publicó en 1998. Por motivos de rendimiento o para ajustarse al formato esperado por las bibliotecas de la época, que se creaban más para problemas de regresión, los conjuntos de datos suelen contener datos codificados de estas formas.

En cualquier caso, antes de continuar, es útil añadir nombres de columnas a este Marco de datos para que sea más fácil trabajar con él:

from pyspark.sql.types import DoubleType
from pyspark.sql.functions import col

colnames = ["Elevation", "Aspect", "Slope", \
            "Horizontal_Distance_To_Hydrology", \
            "Vertical_Distance_To_Hydrology", "Horizontal_Distance_To_Roadways", \
            "Hillshade_9am", "Hillshade_Noon", "Hillshade_3pm", \
            "Horizontal_Distance_To_Fire_Points"] + \ 1
[f"Wilderness_Area_{i}" for i in range(4)] + \
[f"Soil_Type_{i}" for i in range(40)] + \
["Cover_Type"]

data = data_without_header.toDF(*colnames).\
                          withColumn("Cover_Type",
                                    col("Cover_Type").cast(DoubleType()))

data.head()
...
Row(Elevation=2596,Aspect=51,Slope=3,Horizontal_Distance_To_Hydrology=258,...)
1

+ concatena colecciones.

Las columnas relacionadas con los espacios naturales y el suelo se denominan Wilderness_Area_0, Soil_Type_0, etc., y un poco de Python puede generar estos 44 nombres sin tener que escribirlos todos. Por último, la columna de destino Cover_Type se convierte en un valor double por adelantado, porque en realidad será necesario consumirla como double en lugar de int en todas las APIs MLlib de PySpark. Esto se verá más adelante.

Puedes llamar a data.show para ver algunas filas del conjunto de datos, pero la visualización es tan amplia que será difícil leerlo todo. data.head lo muestra como un objeto Row en bruto, que será más legible en este caso.

Ahora que ya conocemos nuestro conjunto de datos y lo hemos procesado, podemos entrenar un modelo de árbol de decisión.

Nuestro primer árbol de decisiones

En el Capítulo 3, construimos un modelo de recomendación de inmediato con todos los datos disponibles. Esto creó un recomendador que podía ser comprobado por cualquier persona con algún conocimiento de música: observando los hábitos de escucha y las recomendaciones de un usuario, teníamos la sensación de que producía buenos resultados. Aquí, eso no es posible. No tendríamos ni idea de cómo confeccionar una descripción de 54 características de una nueva parcela de terreno en Colorado, ni qué tipo de cubierta forestal esperar de dicha parcela.

En su lugar, debemos pasar directamente a reteniendo algunos datos para evaluar el modelo resultante. Antes se utilizaba la métrica AUC para evaluar la concordancia entre los datos de escucha retenidos y las predicciones de las recomendaciones. El AUC puede considerarse como la probabilidad de que una buena recomendación elegida al azar se sitúe por encima de una mala recomendación elegida al azar. El principio es el mismo aquí, aunque la métrica de evaluación será diferente: la precisión. La mayoría -el 90%- de los datos se utilizarán de nuevo para el entrenamiento y, más adelante, veremos que un subconjunto de este conjunto de entrenamiento se reservará para la validación cruzada (el conjunto CV). El otro 10% reservado aquí es en realidad un tercer subconjunto, un conjunto de prueba propiamente dicho.

(train_data, test_data) = data.randomSplit([0.9, 0.1])
train_data.cache()
test_data.cache()

Los datos necesitan un poco más de preparación para ser utilizados con un clasificador en MLlib. El DataFrame de entrada contiene muchas columnas, cada una de las cuales contiene una característica que podría utilizarse para predecir la columna objetivo. MLlib requiere que todas las entradas se reúnan en una columna, cuyo valor es un vector. La clase VectorAssembler de PySpark es una abstracción para los vectores en el sentido del álgebra lineal y sólo contiene números. A efectos prácticos, funcionan como una simple matriz de valores double (números en coma flotante). Por supuesto, algunas de las características de entrada son conceptualmente categóricas, aunque todas estén representadas con números en la entrada.

Afortunadamente, la clase VectorAssembler puede hacer este trabajo:

from pyspark.ml.feature import VectorAssembler

input_cols = colnames[:-1] 1
vector_assembler = VectorAssembler(inputCols=input_cols,
                                    outputCol="featureVector")

assembled_train_data = vector_assembler.transform(train_data)

assembled_train_data.select("featureVector").show(truncate = False)
...
+------------------------------------------------------------------- ...
|featureVector                                                       ...
+------------------------------------------------------------------- ...
|(54,[0,1,2,5,6,7,8,9,13,18],[1874.0,18.0,14.0,90.0,208.0,209.0, ...
|(54,[0,1,2,3,4,5,6,7,8,9,13,18],[1879.0,28.0,19.0,30.0,12.0,95.0, ...
...
1

Excluye la etiqueta, Tipo_cubierta

Los parámetros clave de VectorAssembler son las columnas que hay que combinar en el vector de características, y el nombre de la nueva columna que contiene el vector de características. Aquí, todas las columnas -excepto el objetivo, por supuesto- se incluyen como características de entrada. El DataFrame resultante tiene una nueva columna featureVector, como se muestra.

La salida no parece exactamente una secuencia de números, pero eso es porque muestra una representación en bruto del vector, representado como una instancia de SparseVector para ahorrar almacenamiento. Como la mayoría de los 54 valores son 0, sólo almacena los valores distintos de cero y sus índices. Este detalle no importará en la clasificación.

VectorAssembler es un ejemplo de Transformer dentro de la actual API MLlib Pipelines. Transforma el DataFrame de entrada en otro DataFrame basándose en cierta lógica, y es componible con otras transformaciones en una tubería. Más adelante en este capítulo, estas transformaciones se conectarán en un Pipeline real. Aquí, la transformación sólo se invoca directamente, lo que es suficiente para construir un primer modelo clasificador de árbol de decisión:

from pyspark.ml.classification import DecisionTreeClassifier

classifier = DecisionTreeClassifier(seed = 1234, labelCol="Cover_Type",
                                    featuresCol="featureVector",
                                    predictionCol="prediction")

model = classifier.fit(assembled_train_data)
print(model.toDebugString)
...
DecisionTreeClassificationModel: uid=DecisionTreeClassifier_da03f8ab5e28, ...
  If (feature 0 <= 3036.5)
   If (feature 0 <= 2546.5)
    If (feature 10 <= 0.5)
     If (feature 0 <= 2412.5)
      If (feature 3 <= 15.0)
       Predict: 4.0
      Else (feature 3 > 15.0)
       Predict: 3.0
     Else (feature 0 > 2412.5)
       ...

De nuevo, la configuración esencial para el clasificador consiste en los nombres de las columnas: la columna que contiene los vectores de características de entrada y la columna que contiene el valor objetivo a predecir. Como el modelo se utilizará posteriormente para predecir nuevos valores del objetivo, se le da el nombre de una columna para almacenar las predicciones.

Imprimir una representación del modelo muestra parte de su estructura de árbol. Consiste en una serie de decisiones anidadas sobre las características, comparando los valores de las características con los umbrales. (Aquí, por razones históricas, sólo se hace referencia a las características por su número, no por su nombre, lamentablemente).

Los árboles de decisión son capaces de evaluar la importancia de las características de entrada como parte de su proceso de construcción. Es decir, pueden estimar cuánto contribuye cada característica de entrada a hacer predicciones correctas. Es fácil acceder a esta información desde el modelo:

import pandas as pd

pd.DataFrame(model.featureImportances.toArray(),
            index=input_cols, columns=['importance']).\
            sort_values(by="importance", ascending=False)
...
                                  importance
Elevation                         0.826854
Hillshade_Noon                    0.029087
Soil_Type_1                       0.028647
Soil_Type_3                       0.026447
Wilderness_Area_0                 0.024917
Horizontal_Distance_To_Hydrology  0.024862
Soil_Type_31                      0.018573
Wilderness_Area_2                 0.012458
Horizontal_Distance_To_Roadways   0.003608
Hillshade_9am                     0.002840
...

Esto empareja los valores de importancia (mayor es mejor) con los nombres de las columnas y los imprime en orden de mayor a menor importancia. La elevación parece dominar como característica más importante; ¡se estima que la mayoría de las características no tienen prácticamente ninguna importancia a la hora de predecir el tipo de cubierta!

El DecisionTreeClassificationModel resultante es en sí mismo un transformador, porque puede transformar un marco de datos que contenga vectores de características en un marco de datos que también contenga predicciones.

Por ejemplo, podría ser interesante ver qué predice el modelo sobre los datos de entrenamiento y comparar su predicción con el tipo de cubierta correcto conocido:

predictions = model.transform(assembled_train_data)
predictions.select("Cover_Type", "prediction", "probability").\
            show(10, truncate = False)

...
+----------+----------+------------------------------------------------ ...
|Cover_Type|prediction|probability                                      ...
+----------+----------+------------------------------------------------ ...
|6.0       |4.0       |[0.0,0.0,0.028372324539571926,0.2936784469885515, ...
|6.0       |3.0       |[0.0,0.0,0.024558587479935796,0.6454654895666132, ...
|6.0       |3.0       |[0.0,0.0,0.024558587479935796,0.6454654895666132, ...
|6.0       |3.0       |[0.0,0.0,0.024558587479935796,0.6454654895666132, ...
...

Curiosamente, la salida también contiene una columna probability que da la estimación del modelo sobre la probabilidad de que cada resultado posible sea correcto. Esto muestra que, en estos casos, está bastante seguro de que la respuesta es 3 en varios casos y bastante seguro de que la respuesta no es 1.

Los lectores con ojos de lince observarán que los vectores de probabilidad tienen en realidad ocho valores, aunque sólo haya siete resultados posibles. Los valores del vector en los índices 1 a 7 contienen la probabilidad de los resultados 1 a 7. Sin embargo, también hay un valor en el índice 0, que siempre aparece como probabilidad 0,0. Esto puede ignorarse, ya que el 0 ni siquiera es un resultado válido. Esto puede ignorarse, ya que el 0 ni siquiera es un resultado válido, como se dice aquí. Es una peculiaridad de representar esta información como un vector que merece la pena conocer.

Según el fragmento anterior, parece que al modelo le vendría bien algo de trabajo. Parece que sus predicciones se equivocan a menudo. Al igual que la implementación del ALS del Capítulo 3, la implementación de DecisionTreeClassifier tiene varios hiperparámetros para los que hay que elegir un valor, y aquí se han dejado todos por defecto. Aquí, el conjunto de pruebas puede utilizarse para producir una evaluación insesgada de la precisión esperada de un modelo construido con estos hiperparámetros por defecto.

Ahora utilizaremos MulticlassClassificationEvaluator para calcular la precisión y otras métricas que evalúan la calidad de las predicciones del modelo. Es un ejemplo de evaluador en MLlib, que se encarga de evaluar de algún modo la calidad de un DataFrame de salida:

from pyspark.ml.evaluation import MulticlassClassificationEvaluator

evaluator = MulticlassClassificationEvaluator(labelCol="Cover_Type",
                                        predictionCol="prediction")

evaluator.setMetricName("accuracy").evaluate(predictions)
evaluator.setMetricName("f1").evaluate(predictions)

...
0.6989423087953562
0.6821216079701136

Cuando se le da la columna que contiene la "etiqueta" (objetivo, o valor de salida correcto conocido) y el nombre de la columna que contiene la predicción, comprueba que ambas coinciden aproximadamente el 70% de las veces. Ésta es la precisión de este clasificador. Puede calcular otras medidas relacionadas, como la puntuación F1. Para nuestros propósitos aquí, la precisión se utilizará para evaluar los clasificadores.

Este único número ofrece un buen resumen de la calidad del resultado del clasificador. A veces, sin embargo, puede ser útil mirar la matriz de confusión. Se trata de una tabla con una fila y una columna para cada valor posible del objetivo. Como hay siete valores de la categoría objetivo, se trata de una matriz 7×7, en la que cada fila corresponde a un valor correcto real, y cada columna a un valor predicho, por orden. La entrada en la fila i y la columna j cuenta el número de veces que un ejemplo con la categoría verdadera i se predijo como categoría j. Así pues, las predicciones correctas son los recuentos a lo largo de la diagonal, y las predicciones son todo lo demás.

Es posible calcular una matriz de confusión directamente con la API DataFrame, utilizando sus operadores más generales.

confusion_matrix = predictions.groupBy("Cover_Type").\
  pivot("prediction", range(1,8)).count().\
  na.fill(0.0).\ 1
  orderBy("Cover_Type")

confusion_matrix.show()

...

+----------+------+------+-----+---+---+---+-----+
|Cover_Type|     1|     2|    3|  4|  5|  6|    7|
+----------+------+------+-----+---+---+---+-----+
|       1.0|133792| 51547|  109|  0|  0|  0| 5223|
|       2.0| 57026|192260| 4888| 57|  0|  0|  750|
|       3.0|     0|  3368|28238|590|  0|  0|    0|
|       4.0|     0|     0| 1493|956|  0|  0|    0|
|       5.0|     0|  8282|  283|  0|  0|  0|    0|
|       6.0|     0|  3371|11872|406|  0|  0|    0|
|       7.0|  8122|    74|    0|  0|  0|  0|10319|
+----------+------+------+-----+---+---+---+-----+
1

Sustituye nulo por 0.

Los usuarios de hojas de cálculo pueden haber reconocido que el problema es igual que el de calcular una tabla dinámica. Una tabla pivotante agrupa valores por dos dimensiones, cuyos valores se convierten en filas y columnas de la salida, y calcula alguna agregación dentro de esas agrupaciones, como un recuento aquí. Esto también está disponible como función PIVOT en varias bases de datos y es compatible con Spark SQL. Podría decirse que es más elegante y potente calcularlo de esta forma.

Aunque un 70% de precisión suena decente, no está claro de inmediato si es sobresaliente o deficiente. ¿Qué tal funcionaría un enfoque simplista para establecer una línea de base? Igual que un reloj estropeado acierta dos veces al día, adivinar al azar una clasificación para cada ejemplo también produciría ocasionalmente la respuesta correcta.

Podríamos construir un "clasificador" aleatorio de este tipo eligiendo una clase al azar en proporción a su prevalencia en el conjunto de entrenamiento. Por ejemplo, si el 30% del conjunto de entrenamiento fuera del tipo de cobertura 1, entonces el clasificador aleatorio acertaría "1" el 30% de las veces. Cada clasificación sería correcta en proporción a su prevalencia en el conjunto de pruebas. Si el 40% del conjunto de pruebas fuera del tipo de cobertura 1, entonces adivinar "1" sería correcto el 40% de las veces. El tipo de cobertura 1 sería correcto el 30% x 40% = 12% de las veces y contribuiría en un 12% a la precisión general. Por tanto, podemos evaluar la precisión sumando estos productos de probabilidades:

from pyspark.sql import DataFrame

def class_probabilities(data):
    total = data.count()
    return data.groupBy("Cover_Type").count().\ 1
    orderBy("Cover_Type").\ 2
    select(col("count").cast(DoubleType())).\
    withColumn("count_proportion", col("count")/total).\
    select("count_proportion").collect()


train_prior_probabilities = class_probabilities(train_data)
test_prior_probabilities = class_probabilities(test_data)

train_prior_probabilities
...

[Row(count_proportion=0.36455357859838705),
 Row(count_proportion=0.4875111371136425),
 Row(count_proportion=0.06155716924206445),
 Row(count_proportion=0.00468236760696409),
 Row(count_proportion=0.016375858943914835),
 Row(count_proportion=0.029920118693908142),
 Row(count_proportion=0.03539976980111887)]

...

train_prior_probabilities = [p[0] for p in train_prior_probabilities]
test_prior_probabilities = [p[0] for p in test_prior_probabilities]

sum([train_p * cv_p for train_p, cv_p in zip(train_prior_probabilities,
                                              test_prior_probabilities)]) 3
...

0.37735294664034547
1

Recuento por categoría

2

Recuento de pedidos por categoría

3

Suma de productos de pares en los conjuntos de entrenamiento y prueba

La adivinación aleatoria consigue entonces un 37% de precisión, lo que hace que el 70% parezca un buen resultado después de todo. Pero este último resultado se consiguió con hiperparámetros por defecto. Podemos hacerlo aún mejor explorando qué significan realmente los hiperparámetros para el proceso de construcción del árbol. Eso es lo que haremos en la siguiente sección.

Hiperparámetros del árbol de decisión

En el Capítulo 3, el algoritmo ALS exponía varios hiperparámetros cuyos valores teníamos que elegir construyendo modelos con varias combinaciones de valores y evaluando después la calidad de cada resultado mediante alguna métrica. El proceso es el mismo aquí, aunque ahora la métrica es la precisión multiclase en lugar del AUC. Los hiperparámetros que controlan cómo se eligen las decisiones del árbol también serán bastante diferentes: profundidad máxima, bins máximos, medida de impureza y ganancia de información mínima.

La profundidad máxima simplemente limita el número de niveles en el árbol de decisión. Es el número máximo de decisiones encadenadas que tomará el clasificador para clasificar un ejemplo. Es útil limitarlo para evitar el sobreajuste de los datos de entrenamiento, como se ha ilustrado anteriormente en el ejemplo de la tienda de mascotas.

El algoritmo del árbol de decisión se encarga de proponer posibles reglas de decisión para probar en cada nivel, como las decisiones weight >= 100 o weight >= 500 en el ejemplo de la tienda de mascotas. Las decisiones tienen siempre la misma forma: para los rasgos numéricos, las decisiones tienen la forma feature >= value; y para los rasgos categóricos, tienen la forma feature in (value1, value2, …). Por tanto, el conjunto de reglas de decisión que hay que probar es en realidad un conjunto de valores que hay que introducir en la regla de decisión. En la implementación de PySpark MLlib, se denominan intervalos. Un mayor número de bins requiere más tiempo de procesamiento, pero puede llevar a encontrar una regla de decisión más óptima.

¿Qué hace que una regla de decisión sea buena? Intuitivamente, una buena regla distinguiría de forma significativa los ejemplos según el valor de la categoría objetivo. Por ejemplo, una regla que dividiera el conjunto de datos Covtype en ejemplos con sólo las categorías 1-3, por un lado, y 4-7, por otro, sería excelente porque separa claramente unas categorías de otras. Una regla que diera como resultado aproximadamente la misma mezcla de todas las categorías que se encuentran en todo el conjunto de datos no parece útil. Seguir cualquiera de las dos ramas de una decisión de este tipo conduce aproximadamente a la misma distribución de posibles valores objetivo, por lo que no avanza realmente hacia una clasificación segura.

Dicho de otro modo, las buenas reglas dividen los valores objetivo de los datos de entrenamiento en subconjuntos relativamente homogéneos, o "puros". Elegir la mejor regla significa minimizar la impureza de los dos subconjuntos que induce. Hay dos medidas de impureza de uso común: La impureza de Gini y la entropía.

La impureza de Gini está directamente relacionada con la precisión del clasificador de conjeturas aleatorias. Dentro de un subconjunto, es la probabilidad de que una clasificación elegida al azar de un ejemplo elegido al azar (ambos según la distribución de clases del subconjunto) seaincorrecta. Para calcular este valor, primero multiplicamos cada clase por su proporción respectiva entre todas las clases. Luego restamos la suma de todos los valores de 1. Si un subconjunto tiene N clases ypi es la proporción de ejemplos de la clase i, su impureza de Gini viene dada en la ecuación de impureza de Gini:

I G ( p ) = 1 - i=1 N p i 2

Si el subconjunto sólo contiene una clase, este valor es 0 porque es completamente "puro". Cuando hay N clases en el subconjunto, este valor es mayor que 0 y es mayor cuando las clases aparecen el mismo número de veces -máximamente impuro-.

La entropía es otra medida de impureza, tomada de la teoría de la información. Su naturaleza es más difícil de explicar, pero capta el grado de incertidumbre que implica la colección de valores objetivo del subconjunto sobre las predicciones de los datos que entran en ese subconjunto. Un subconjunto que contiene una clase sugiere que el resultado del subconjunto es completamente seguro y tiene 0 entropía, es decir, ninguna incertidumbre. En cambio, un subconjunto que contenga una de cada clase posible sugiere mucha incertidumbre sobre las predicciones para ese subconjunto, porque se han observado datos con todo tipo de valores objetivo. Esto tiene una entropía alta. Por tanto, una entropía baja, como una impureza de Gini baja, es algo bueno. La entropía se define mediante la ecuación de entropía:

I E ( p ) = i=1 N p i registro ( 1 p i ) = - i=1 N p i registro ( p i )

Curiosamente, la incertidumbre tiene unidades. Como el logaritmo es el logaritmo natural (base e), las unidades son nats, la contrapartida en base e de los más familiares bits (que podemos obtener utilizando en su lugar log base 2). Realmente está midiendo la información, por lo que también es habitual hablar de la ganancia de información de una regla de decisión cuando se utiliza la entropía con árboles de decisión.

Una u otra medida puede ser una métrica mejor para elegir reglas de decisión en un conjunto de datos determinado. En cierto modo, son similares. Ambas implican una media ponderada: una suma sobre valores ponderados porpi. Por defecto, la implementación de PySpark es la impureza de Gini.

Por último, la ganancia mínima de información es un hiperparámetro que impone una ganancia mínima de información, o disminución de la impureza, para las reglas de decisión candidatas. Se rechazan las reglas que no mejoran lo suficiente la impureza de los subconjuntos. Al igual que una profundidad máxima más baja, esto puede ayudar al modelo a resistir el sobreajuste, porque las decisiones que apenas ayudan a dividir la entrada de entrenamiento pueden, de hecho, no ayudar en absoluto a dividir los datos futuros.

Ahora que entendemos los hiperparámetros relevantes de un algoritmo de árbol de decisión, en la siguiente sección ajustaremos nuestro modelo para mejorar su rendimiento.

Ajuste de los árboles de decisión

No es obvio, mirando los datos , qué medida de impureza conduce a una mayor precisión o qué profundidad máxima o número de bins es suficiente sin ser excesivo. Afortunadamente, como en el Capítulo 3, es sencillo dejar que PySpark pruebe varias combinaciones de estos valores e informe de los resultados.

En primer lugar, es necesario configurar una canalización que encapsule los dos pasos que realizamos en secciones anteriores: crear un vector de características y utilizarlo para crear un modelo de árbol de decisión. Creando VectorAssembler y DecisionTreeClassifier y encadenando estos dos Transformers se obtiene un único objeto Pipeline que representa estas dos operaciones juntas como una sola:

from pyspark.ml import Pipeline

assembler = VectorAssembler(inputCols=input_cols, outputCol="featureVector")
classifier = DecisionTreeClassifier(seed=1234, labelCol="Cover_Type",
                                    featuresCol="featureVector",
                                    predictionCol="prediction")

pipeline = Pipeline(stages=[assembler, classifier])

Naturalmente, las tuberías pueden ser mucho más largas y complejas. Esto es lo más sencillo que se puede hacer. Ahora también podemos definir las combinaciones de hiperparámetros que deben probarse utilizando el soporte integrado de la API ML de PySpark, ParamGridBuilder. También es hora de definir la métrica de evaluación que se utilizará para elegir los "mejores" hiperparámetros, y eso es de nuevo MulticlassClassificationEvaluator:

from pyspark.ml.tuning import ParamGridBuilder

paramGrid = ParamGridBuilder(). \
  addGrid(classifier.impurity, ["gini", "entropy"]). \
  addGrid(classifier.maxDepth, [1, 20]). \
  addGrid(classifier.maxBins, [40, 300]). \
  addGrid(classifier.minInfoGain, [0.0, 0.05]). \
  build()

multiclassEval = MulticlassClassificationEvaluator(). \
  setLabelCol("Cover_Type"). \
  setPredictionCol("prediction"). \
  setMetricName("accuracy")

Esto significa que se construirá y evaluará un modelo para dos valores de cuatro hiperparámetros. Son 16 modelos. Se evaluarán según la precisión multiclase. Por último, TrainValidationSplit reúne estos componentes -la tubería que crea los modelos, las métricas de evaluación de los modelos y los hiperparámetros a probar- y puede ejecutar la evaluación en los datos de entrenamiento. Cabe señalar que CrossValidator también podría utilizarse aquí para realizar una validación cruzada k-fold completa, pero es k veces más cara y no añade tanto valor en presencia de big data. Por tanto, aquí se utiliza TrainValidationSplit:

from pyspark.ml.tuning import TrainValidationSplit

validator = TrainValidationSplit(seed=1234,
  estimator=pipeline,
  evaluator=multiclassEval,
  estimatorParamMaps=paramGrid,
  trainRatio=0.9)

validator_model = validator.fit(train_data)

Esto tardará varios minutos o más, dependiendo de tu hardware, porque está construyendo y evaluando muchos modelos. Observa que el parámetro relación de entrenamiento está ajustado a 0,9. Esto significa que, en realidad, los datos de entrenamiento se subdividen en TrainValidationSplit en subconjuntos de 90%/10%. Los primeros se utilizan para entrenar cada modelo. El 10% restante se utiliza como conjunto de validación cruzada para evaluar el modelo. Si ya retiene algunos datos para la evaluación, ¿por qué retuvimos el 10% de los datos originales como conjunto de prueba?

Si el propósito del conjunto CV era evaluar los parámetros que se ajustaban al conjunto de entrenamiento, entonces el propósito del conjunto de prueba es evaluar los hiperparámetros que se "ajustaban" al conjunto CV. Es decir, el conjunto de prueba garantiza una estimación no sesgada de la precisión del modelo final elegido y sus hiperparámetros.

Digamos que el mejor modelo elegido mediante este proceso muestra una precisión del 90% en el conjunto de CV. Parece razonable esperar que muestre una precisión del 90% en los datos futuros. Sin embargo, hay un elemento de aleatoriedad en cómo se construyen estos modelos. Por casualidad, este modelo y esta evaluación podrían haber salido inusualmente bien. El mejor resultado del modelo y la evaluación podría haberse beneficiado de un poco de suerte, por lo que es probable que su estimación de precisión sea ligeramente optimista. Dicho de otro modo, los hiperparámetros también pueden sobreajustarse.

Para evaluar realmente el rendimiento de este mejor modelo en ejemplos futuros, tenemos que evaluarlo en ejemplos que no se utilizaron para entrenarlo. Pero también tenemos que evitar los ejemplos del conjunto CV que se utilizaron para evaluarlo. Por eso se reservó un tercer subconjunto, el conjunto de prueba.

El resultado del validador contiene el mejor modelo que ha encontrado. Éste, a su vez, es una representación de la mejor cadena global que ha encontrado, porque le hemos proporcionado una instancia de una cadena para que la ejecute. Para consultar los parámetros elegidos por DecisionTreeClassifier, es necesario extraer manualmente DecisionTreeClassificationModel del resultado PipelineModel, que es la etapa final de la canalización:

from pprint import pprint

best_model = validator_model.bestModel
pprint(best_model.stages[1].extractParamMap())

...
{Param(...name='predictionCol', doc='prediction column name.'): 'prediction',
 Param(...name='probabilityCol', doc='...'): 'probability',
 [...]
 Param(...name='impurity', doc='...'): 'entropy',
 Param(...name='maxDepth', doc='...'): 20,
 Param(...name='minInfoGain', doc='...'): 0.0,
 [...]
 Param(...name='featuresCol', doc='features column name.'): 'featureVector',
 Param(...name='maxBins', doc='...'): 40,
 [...]
 Param(...name='labelCol', doc='label column name.'): 'Cover_Type'}
 ...
}

Esta salida contiene mucha información sobre el modelo ajustado, pero también nos dice que, aparentemente, la entropía funcionó mejor como medida de impureza y que una profundidad máxima de 20 no fue sorprendentemente mejor que 1. Puede sorprender que el mejor modelo se ajustara con sólo 40 bins, pero probablemente sea una señal de que 40 era "suficiente" más que "mejor" que 300. Por último, ningún mínimo de ganancia de información era mejor que un mínimo pequeño, lo que podría implicar que el modelo es más propenso al infraajuste que al sobreajuste.

Quizá te preguntes si es posible ver la precisión que alcanzó cada uno de los modelos para cada combinación de hiperparámetros. Los hiperparámetros y las evaluaciones se exponen mediante getEstimatorParamMaps y validationMetrics, respectivamente. Pueden combinarse para mostrar todas las combinaciones de parámetros ordenadas por valor métrico:

validator_model = validator.fit(train_data)

metrics = validator_model.validationMetrics
params = validator_model.getEstimatorParamMaps()
metrics_and_params = list(zip(metrics, params))

metrics_and_params.sort(key=lambda x: x[0], reverse=True)
metrics_and_params

...
[(0.9130409881445563,
  {Param(...name='minInfoGain' ...): 0.0,
   Param(...name='maxDepth'...): 20,
   Param(...name='maxBins' ...): 40,
   Param(...name='impurity'...): 'entropy'}),
 (0.9112655352131498,
  {Param(...name='minInfoGain',...): 0.0,
   Param(...name='maxDepth' ...): 20,
   Param(...name='maxBins'...): 300,
   Param(...name='impurity'...: 'entropy'}),
...

¿Cuál fue la precisión que alcanzó este modelo en el conjunto CV? Y, por último, ¿qué precisión alcanza el modelo en el conjunto de pruebas?

metrics.sort(reverse=True)
print(metrics[0])
...

0.9130409881445563
...

multiclassEval.evaluate(best_model.transform(test_data)) 1

...
0.9138921373048084
1

best_Model es una tubería completa.

Ambos resultados rondan el 91%. Ocurre que la estimación del conjunto CV era bastante buena para empezar. De hecho, no es habitual que el conjunto de prueba muestre un resultado muy diferente.

Este es un punto interesante en el que volver a visitar la cuestión del sobreajuste. Como se ha comentado anteriormente, es posible construir un árbol de decisión tan profundo y elaborado que se ajuste muy bien o perfectamente a los ejemplos de entrenamiento dados, pero que no consiga generalizar a otros ejemplos porque se ha ajustado demasiado a la idiosincrasia y al ruido de los datos de entrenamiento. Éste es un problema común a la mayoría de los algoritmos de aprendizaje automático, no sólo a los árboles de decisión.

Cuando un árbol de decisión se ha sobreajustado, mostrará una gran precisión cuando se ejecute con los mismos datos de entrenamiento con los que ajustó el modelo, pero una precisión baja en otros ejemplos. En este caso, la precisión del modelo final fue de aproximadamente el 91% en otros ejemplos nuevos. La precisión puede evaluarse fácilmente con los mismos datos con los que se entrenó el modelo, trainData. Esto da una precisión de aproximadamente el 95%. La diferencia no es grande, pero sugiere que el árbol de decisión ha sobreajustado en cierta medida los datos de entrenamiento. Una profundidad máxima más baja podría ser una mejor elección.

Hasta ahora, hemos tratado implícitamente todas las características de entrada, incluidas las categóricas, como si fueran numéricas. ¿Podemos mejorar aún más el rendimiento de nuestro modelo tratando las características categóricas exactamente como eso? Lo exploraremos a continuación.

Características categóricas revisadas

Las características categóricas de nuestro conjunto de datos están codificadas como varios valores binarios 0/1. Tratar estas características individuales como numéricas resulta estar bien, porque cualquier regla de decisión sobre las características "numéricas" elegirá umbrales entre 0 y 1, y todas son equivalentes, ya que todos los valores son 0 ó 1.

Por supuesto, esta codificación obliga al algoritmo del árbol de decisión a considerar individualmente los valores de las características categóricas subyacentes. Como las características como el tipo de suelo se descomponen en muchas características y como los árboles de decisión tratan las características individualmente, es más difícil relacionar la información sobre tipos de suelo relacionados.

Por ejemplo, nueve tipos de suelo diferentes forman parte en realidad de la familia Leighton, y pueden estar relacionados de formas que el árbol de decisión puede explotar. Si el tipo de suelo se codificara como una única característica categórica con 40 valores de suelo, entonces el árbol podría expresar directamente reglas como "si el tipo de suelo es uno de los nueve tipos de la familia Leighton". Sin embargo, cuando se codifica como 40 rasgos, el árbol tendría que aprender una secuencia de nueve decisiones sobre el tipo de suelo para hacer lo mismo, esta expresividad puede dar lugar a mejores decisiones y a árboles más eficientes.

Sin embargo, tener 40 características numéricas que representen una característica categórica de 40 valores aumenta el uso de memoria y ralentiza las cosas.

¿Qué te parece deshacer la codificación de un solo punto? Esto sustituiría, por ejemplo, las cuatro columnas que codifican el tipo de desierto por una columna que codifique el tipo de desierto como un número entre 0 y 3, como Cover_Type:

def unencode_one_hot(data):
    wilderness_cols = ['Wilderness_Area_' + str(i) for i in range(4)]
    wilderness_assembler = VectorAssembler().\
                            setInputCols(wilderness_cols).\
                            setOutputCol("wilderness")

    unhot_udf = udf(lambda v: v.toArray().tolist().index(1)) 1

    with_wilderness = wilderness_assembler.transform(data).\
      drop(*wilderness_cols).\ 2
      withColumn("wilderness", unhot_udf(col("wilderness")))

    soil_cols = ['Soil_Type_' + str(i) for i in range(40)]
    soil_assembler = VectorAssembler().\
                      setInputCols(soil_cols).\
                      setOutputCol("soil")
    with_soil = soil_assembler.\
                transform(with_wilderness).\
                drop(*soil_cols).\
                withColumn("soil", unhot_udf(col("soil")))

    return with_soil
1

Nota Definición UDF

2

Elimina las columnas de un solo punto; ya no son necesarias

Aquí se implementa VectorAssembler para combinar las columnas 4 y 40 de naturaleza y tipo de suelo en dos columnas Vector. Los valores de estas Vectorson todos 0, excepto una ubicación que tiene un 1. No existe una función DataFrame sencilla para esto, así que tenemos que definir nuestra propia UDF que pueda utilizarse para operar sobre las columnas. Esto convierte estas dos nuevas columnas en números del tipo que necesitamos.

Ahora podemos transformar nuestro conjunto de datos eliminando la codificación de un solo golpe mediante nuestra función definida anteriormente:

unenc_train_data = unencode_one_hot(train_data)
unenc_train_data.printSchema()
...
root
 |-- Elevation: integer (nullable = true)
 |-- Aspect: integer (nullable = true)
 |-- Slope: integer (nullable = true)
 |-- Horizontal_Distance_To_Hydrology: integer (nullable = true)
 |-- Vertical_Distance_To_Hydrology: integer (nullable = true)
 |-- Horizontal_Distance_To_Roadways: integer (nullable = true)
 |-- Hillshade_9am: integer (nullable = true)
 |-- Hillshade_Noon: integer (nullable = true)
 |-- Hillshade_3pm: integer (nullable = true)
 |-- Horizontal_Distance_To_Fire_Points: integer (nullable = true)
 |-- Cover_Type: double (nullable = true)
 |-- wilderness: string (nullable = true)
 |-- soil: string (nullable = true)
...

unenc_train_data.groupBy('wilderness').count().show()
...

+----------+------+
|wilderness| count|
+----------+------+
|         3| 33271|
|         0|234532|
|         1| 26917|
|         2|228144|
+----------+------+

A partir de aquí, se puede utilizar prácticamente el mismo proceso anterior para ajustar los hiperparámetros de un modelo de árbol de decisión construido sobre estos datos y elegir y evaluar el mejor modelo. Sin embargo, hay una diferencia importante. Las dos nuevas columnas numéricas no tienen nada que indique que en realidad son una codificación de valores categóricos. Tratarlas como números no es correcto, ya que su ordenación carece de sentido. El modelo seguirá construyéndose, pero debido a que parte de la información de estas características no está disponible, la precisión puede verse afectada.

Internamente, MLlib puede almacenar metadatos adicionales sobre cada columna. Los detalles de estos datos suelen estar ocultos para quien los llama, pero incluyen información como si la columna codifica un valor categórico y cuántos valores distintos adopta. Para añadir estos metadatos, es necesario pasar los datos por VectorIndexer. Su trabajo consiste en convertir la entrada en columnas de características categóricas debidamente etiquetadas. Aunque ya hicimos gran parte del trabajo para convertir las características categóricas en valores indexados 0, VectorIndexer se encargará de los metadatos.

Tenemos que añadir esta etapa a Pipeline:

from pyspark.ml.feature import VectorIndexer

cols = unenc_train_data.columns
inputCols = [c for c in cols if c!='Cover_Type']

assembler = VectorAssembler().setInputCols(inputCols).setOutputCol("featureVector")

indexer = VectorIndexer().\
  setMaxCategories(40).\ 1
  setInputCol("featureVector").setOutputCol("indexedVector")

classifier = DecisionTreeClassifier().setLabelCol("Cover_Type").\
                                      setFeaturesCol("indexedVector").\
                                      setPredictionCol("prediction")

pipeline = Pipeline().setStages([assembler, indexer, classifier])
1

>= 40 porque el suelo tiene 40 valores

El enfoque supone que el conjunto de entrenamiento contiene todos los valores posibles de cada una de las características categóricas al menos una vez. Es decir, sólo funciona correctamente si los 4 valores del suelo y los 40 valores de la naturaleza aparecen en el conjunto de entrenamiento, de modo que todos los valores posibles obtengan un mapeo. En este caso, eso resulta ser cierto, pero puede no serlo para pequeños conjuntos de datos de entrenamiento en los que algunas etiquetas aparecen con muy poca frecuencia. En esos casos, podría ser necesario crear y añadir manualmente un VectorIndexerModel con el mapeo de valores completo suministrado manualmente.

Aparte de eso, el proceso es el mismo que antes. Deberías comprobar que eligió un mejor modelo similar, pero que la precisión en el conjunto de pruebas es de aproximadamente el 93%. Al tratar las características categóricas como características categóricas reales en las secciones anteriores, el clasificador mejoró su precisión en casi un 2%.

Hemos entrenado y afinado un árbol de decisión. Ahora pasaremos a los bosques aleatorios, un algoritmo más potente. Como veremos en la siguiente sección, implementarlos utilizando PySpark será sorprendentemente sencillo en este punto.

Bosques aleatorios

Si has seguido los ejemplos de código, habrás notado que tus resultados difieren ligeramente de los presentados en los listados de código del libro. Esto se debe a que hay un elemento de aleatoriedad en la construcción de árboles de decisión, y la aleatoriedad entra en juego cuando decides qué datos utilizar y qué reglas de decisión explorar.

El algoritmo no considera todas las reglas de decisión posibles en todos los niveles. Hacerlo llevaría una cantidad de tiempo increíble. Para una característica categórica sobre N valores, hay 2N-2posibles reglas de decisión (cada subconjunto excepto el conjunto vacío y el conjunto entero). Para un N incluso moderadamente grande, esto crearía miles de millones de reglas de decisión candidatas.

En su lugar, los árboles de decisión utilizan varias heurísticas para determinar qué pocas reglas deben tenerse realmente en cuenta. El proceso de selección de reglas también implica cierta aleatoriedad; cada vez se tienen en cuenta sólo unas pocas características elegidas al azar, y sólo valores de un subconjunto aleatorio de los datos de entrenamiento. Esto cambia un poco de precisión por mucha velocidad, pero también significa que el algoritmo del árbol de decisión no construirá siempre el mismo árbol. Esto es bueno.

Es bueno por la misma razón por la que la "sabiduría de la multitud" suele superar a las predicciones individuales. Para ilustrarlo, haz este rápido test: ¿cuántos taxis negros operan en Londres?

No mires la respuesta; adivínala primero.

Adiviné 10.000, que está muy lejos de la respuesta correcta de unos 19.000. Como yo he acertado poco, es más probable que tú hayas acertado más que yo, por lo que la media de nuestras respuestas tenderá a ser más exacta. Otra vez la regresión a la media. La media obtenida en una encuesta informal entre 13 personas de la oficina estaba más cerca: 11.170.

La clave de este efecto es que las conjeturas eran independientes y no se influían entre sí. (El ejercicio sería inútil si todos nos hubiéramos puesto de acuerdo y hubiéramos utilizado la misma metodología para hacer una conjetura, porque las conjeturas habrían sido la misma respuesta, la misma respuesta potencialmente bastante errónea. Incluso habría sido diferente y peor si me hubiera limitado a influir en ti indicando mi suposición por adelantado.

Sería estupendo tener no un árbol, sino muchos árboles, cada uno de los cuales produjera estimaciones razonables pero diferentes e independientes del valor objetivo correcto. Su predicción media colectiva debería acercarse más a la respuesta verdadera que la de cualquier árbol individual. Es la aleatoriedad en el proceso de construcción lo que ayuda a crear esta independencia. Ésta es la clave de los bosques aleatorios.

La aleatoriedad se inyecta construyendo muchos árboles, cada uno de los cuales ve un subconjunto aleatorio diferente de datos e incluso de características. Esto hace que el bosque en su conjunto sea menos propenso al sobreajuste. Si una característica concreta contiene datos ruidosos o es engañosamente predictiva sólo en el conjunto de entrenamiento, la mayoría de los árboles no tendrán en cuenta esta característica problemática la mayor parte del tiempo. La mayoría de los árboles no se ajustarán al ruido y tenderán a "superar" a los árboles que sí se han ajustado al ruido en el bosque.

La predicción de un bosque aleatorio es simplemente una media ponderada de las predicciones de los árboles. Para un objetivo categórico, puede ser un voto mayoritario o el valor más probable basado en la media de las probabilidades producidas por los árboles. Los bosques aleatorios, como los árboles de decisión, también admiten la regresión, y la predicción del bosque en este caso es la media del número predicho por cada árbol.

Aunque los bosques aleatorios son una técnica de clasificación más potente y compleja, la buena noticia es que no supone prácticamente ninguna diferencia utilizarlos en la tubería que se ha desarrollado en este capítulo. Basta con introducir un RandomForestClassifier en lugar de DecisionTreeClassifier y proceder como antes. Realmente no hay más código ni API que entender para utilizarlo:

from pyspark.ml.classification import RandomForestClassifier

classifier = RandomForestClassifier(seed=1234, labelCol="Cover_Type",
                                    featuresCol="indexedVector",
                                    predictionCol="prediction")

Ten en cuenta que este clasificador tiene otro hiperparámetro: el número de árboles a construir. Al igual que el hiperparámetro max bins, los valores más altos deberían dar mejores resultados hasta cierto punto. El coste, sin embargo, es que construir muchos árboles, por supuesto, lleva muchas veces más tiempo que construir uno.

La precisión del mejor modelo de bosque aleatorio producido a partir de un proceso de ajuste similar es del 95% desde el principio, lo que supone un 2% más, aunque visto de otra forma, se trata de una reducción del 28% en la tasa de error sobre el mejor árbol de decisión construido anteriormente, del 7% al 5%. Puede que consigas mejores resultados con más ajustes.

Por cierto, en este punto tenemos una imagen más fiable de la importancia de los rasgos:

forest_model = best_model.stages[1]

feature_importance_list = list(zip(input_cols,
                                  forest_model.featureImportances.toArray()))
feature_importance_list.sort(key=lambda x: x[1], reverse=True)

pprint(feature_importance_list)
...
(0.28877055118903183,Elevation)
(0.17288279582959612,soil)
(0.12105056811661499,Horizontal_Distance_To_Roadways)
(0.1121550648692802,Horizontal_Distance_To_Fire_Points)
(0.08805270405239551,wilderness)
(0.04467393191338021,Vertical_Distance_To_Hydrology)
(0.04293099150373547,Horizontal_Distance_To_Hydrology)
(0.03149644050848614,Hillshade_Noon)
(0.028408483578137605,Hillshade_9am)
(0.027185325937200706,Aspect)
(0.027075578474331806,Hillshade_3pm)
(0.015317564027809389,Slope)

Los bosques aleatorios son atractivos en el contexto de los big data porque se supone que los árboles se construyen de forma independiente, y las tecnologías de big data como Spark y MapReduce necesitan inherentemente problemas de datos paralelos, en los que partes de la solución global puedan calcularse independientemente en partes de los datos. El hecho de que los árboles puedan, y deban, entrenarse sólo en un subconjunto de características o datos de entrada, hace que sea trivial paralelizar la construcción de los árboles.

Hacer predicciones

Construir un clasificador, aunque es un proceso interesante y lleno de matices, no es el objetivo final. El objetivo es hacer predicciones. Esta es la recompensa, y comparativamente es bastante fácil.

El "mejor modelo" resultante es, en realidad, toda una cadena de operaciones. Encierra cómo se transforma la entrada para utilizarla con el modelo e incluye el propio modelo, que puede hacer predicciones. Puede operar sobre un marco de datos de nueva entrada. La única diferencia con el marco de datos data con el que empezamos es que carece de la columna Cover_Type. Cuando hacemos predicciones -especialmente sobre el futuro, dice el Sr. Bohr- la salida, por supuesto, no se conoce.

Para probarlo, prueba a eliminar el Cover_Type de la entrada de datos de prueba y obtener una predicción:

unenc_test_data = unencode_one_hot(test_data)
bestModel.transform(unenc_test_data.drop("Cover_Type")).\
                    select("prediction").show()

...
+----------+
|prediction|
+----------+
|       6.0|
+----------+

El resultado debe ser 6,0, que corresponde a la clase 7 (la característica original tenía un índice 1) en el conjunto de datos Covtype original. El tipo de cobertura previsto para el terreno descrito en este ejemplo es Krummholz.

¿Adónde vamos ahora?

En este capítulo se han presentado dos tipos importantes y relacionados de aprendizaje automático, la clasificación y la regresión, junto con algunos conceptos básicos para construir y ajustar modelos: características, vectores, entrenamiento y validación cruzada. Se ha demostrado cómo predecir un tipo de cubierta forestal a partir de datos como la ubicación y el tipo de suelo utilizando el conjunto de datos Covtype, con árboles de decisión y bosques implementados en PySpark.

Como en el caso de los recomendadores del Capítulo 3, podría ser útil seguir explorando el efecto de los hiperparámetros sobre la precisión. La mayoría de los hiperparámetros de los árboles de decisión intercambian tiempo por precisión: más intervalos y árboles producen generalmente una mayor precisión, pero llegan a un punto de rendimiento decreciente.

Aquí el clasificador resultó ser muy preciso. No es habitual lograr más de un 95% de precisión. En general, conseguirás más mejoras en la precisión incluyendo más características o transformando las existentes en una forma más predictiva. Éste es un paso común y repetido en la mejora iterativa de un modelo clasificador. Por ejemplo, para este conjunto de datos, las dos características que codifican las características de distancia horizontal y vertical a la superficie del agua podrían producir una tercera característica: características de distancia en línea recta a la superficie del agua. Esto podría resultar más útil que cualquiera de las características originales. O, si fuera posible recoger más datos, podríamos intentar añadir nueva información, como la humedad del suelo, para mejorar la clasificación.

Por supuesto, no todos los problemas de predicción del mundo real son exactamente como el conjunto de datos Covtype. Por ejemplo, algunos problemas requieren predecir un valor numérico continuo, no un valor categórico. Gran parte del mismo análisis y código se aplica a este tipo de problema de regresión; la clase RandomForestRegressor será útil en este caso.

Además, los árboles de decisión y los bosques no son los únicos algoritmos de clasificación o regresión, ni los únicos implementados en PySpark. Cada algoritmo funciona de forma muy distinta a los árboles de decisión y los bosques. Sin embargo, muchos elementos son los mismos: se conectan a Pipeline y operan sobre columnas de un marco de datos, y tienen hiperparámetros que debes seleccionar utilizando subconjuntos de entrenamiento, validación cruzada y prueba de los datos de entrada. Los mismos principios generales, con estos otros algoritmos, también pueden implementarse para modelizar problemas de clasificación y regresión.

Estos han sido ejemplos de aprendizaje supervisado. ¿Qué ocurre cuando algunos, o todos, los valores objetivo son desconocidos? El capítulo siguiente explorará lo que se puede hacer en esta situación.

Get Analítica avanzada con PySpark now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.