Nota
El acceso a esta página requiere autorización. Puede intentar iniciar sesión o cambiar directorios.
El acceso a esta página requiere autorización. Puede intentar cambiar los directorios.
Clasificación probit con C#
Descargar el código de muestra
La clasificación probit (del inglés "probability unit", unidad de probabilidad) es una técnica de aprendizaje automático que se puede utilizar para realizar predicciones en situaciones donde la variable dependiente que se quiere predecir es binaria, es decir, que puede tomar un valor de únicamente dos posibles. La clasificación probit también se conoce como regresión probit y como modelado probit.
La clasificación probit es muy similar a la clasificación por regresión logística. Las dos técnicas se aplican a los mismos tipos de problemas y suelen dar resultados similares; la elección de usar clasificación probit o por regresión logística normalmente depende de la disciplina en la que esté trabajando. Probit se usa a menudo en economía y finanzas, mientras que la regresión logística es más común en otros campos.
Para comprender en lo que consiste la clasificación probit, eche un vistazo al programa de demostración de la Figura 1.
Figura 1. La clasificación probit en acción
La demostración usa la clasificación probit para crear un modelo que prediga si el paciente de un hospital morirá, basándose en su edad (age), sexo (sex) y los resultados de una prueba de riñón. Los datos son completamente inventados. El primer elemento de los datos sin procesar es:
48.00 +1.00 4.40 0
Los datos sin procesar están compuestos por 30 elementos. Sex se codifica como -1 para masculino y +1 para femenino. La variable que se quiere predecir, Died (muerto), está en la última columna y se codifica como 0 = falso (por lo que la persona sobrevivió) y 1 = verdadero. Por tanto, el primer elemento de datos indica una mujer de 48 años con una puntuación del riñón (kidney) de 4,40 que sobrevivió. La demostración comienza con la normalización de los datos de age y kidney de forma que todos los valores tengan aproximadamente la misma magnitud. El primer elemento de datos, después de la normalización, es:
-0.74 +1.00 -0.61 0.00
Los valores normalizados menores de 0,0 (aquí, tanto la puntuación de age como kidney) están por debajo de la media y los valores mayores que 0,0 están por encima.
Los datos originales se dividen de forma aleatoria en un conjunto de entrenamiento de 24 elementos para crear el modelo, junto con un conjunto de prueba de 6 elementos para estimar la precisión del modelo cuando se aplica a nuevos datos con resultados desconocidos.
El programa de demostración crea entonces un modelo probit. En segundo plano, se lleva a cabo el entrenamiento mediante una técnica llamada optimización simplex, con un número de iteraciones máximas establecido en 100. Después del entrenamiento, se muestran las ponderaciones que definen el modelo { -4,26;2,30; -1,29;3,45 }.
El primer valor de ponderación, -4,26, es una constante general y no se refiere a ninguna variable indicadora específica. El segundo valor de ponderación, 2,30, se refiere a age; el tercero, -1,29, a sex; y el cuarto, 3,45, a la puntuación de kidney. Las ponderaciones positivas, como las que se asocian con age y con la puntuación de kidney, implican valores mayores del indicador que implican que la variable dependiente, Died, será mayor (y por tanto, más cercana a verdadero).
La demostración calcula la precisión del modelo para los 24 elementos del conjunto de entrenamiento (100 % correcto) y en el conjunto de prueba (83,33 %, o cinco correctos y uno incorrecto). El valor más significativo de estos dos es la precisión de la prueba. Es una estimación aproximada de la precisión total del modelo probit.
Este artículo presupone que sus habilidades de programación son, al menos, intermedias, y que tiene un conocimiento básico de la clasificación mediante aprendizaje automático; pero no presupone que conozca nada acerca de la clasificación probit. El programa de demostración está codificado en C#, pero debería ser capaz de refactorizarlo a otros lenguajes .NET sin demasiado esfuerzo. El código de demostración es demasiado largo para presentarlo en su totalidad, pero todo el código está disponible en la descarga que acompaña este artículo en msdn.microsoft.com/magazine/msdnmag1014. Se han eliminado todas las comprobaciones de errores normales para mantener las ideas principales claras.
Explicación de la clasificación probit
Una forma simple para predecir la muerte a partir de la puntuación de age, sex y kidney sería formar una combinación lineal a lo largo de las líneas de:
died = b0 + (b1)(age) + (b2)(sex) + (b3)(kidney)
donde b0, b1, b2 y b3 son ponderaciones que deben determinarse de alguna manera, de forma que los valores de salida calculados a partir de los datos de entrenamiento se aproximen mucho a los valores de las variables dependientes conocidos. La regresión logística amplía esta idea con una función de predicción más compleja:
z = b0 + (b1)(age) + (b2)(sex) + (b3)(kidney)
died = 1.0 / (1.0 + e-z)
Los conceptos matemáticos son avanzados, pero la función de predicción, llamada función sigmoidal logística, devuelve siempre (por conveniencia) un valor entre 0,0 y 1,0, que puede interpretarse como una probabilidad. La clasificación probit usa una función de predicción distinta:
z = b0 + (b1)(age) + (b2)(sex) + (b3)(kidney)
died = Phi(z)
A la función Phi(z) se la conoce como función de distribución acumulativa normal estándar (que en adelante se abreviará como CDF) y siempre devuelve un valor entre 0,0 y 1,0. La CDF es un poco delicada porque no hay una ecuación simple para calcularla. La CDF de un valor z es el área por debajo de la famosa curva de campana de Gauss (la función gaussiana) desde menos infinito hasta z.
Todo esto suena mucho más complicado de lo que realmente es. Eche un vistazo al gráfico de la Figura 2. En el gráfico se puede apreciar la función sigmoidal logística y la función CDF trazadas una al lado de la otra. La cuestión importante es que para cualquier valor z, aunque las funciones subyacentes son muy distintas, ambas funciones devuelven un valor entre 0,0 y 1,0 que se puede interpretar como una probabilidad.
Figura 2. El gráfico de la función de distribución acumulativa
Por tanto, desde el punto de vista de un desarrollador, el primer reto es escribir una función que calcule el CDF para un valor z. No hay una ecuación simple que calcule el CDF, pero hay decenas de aproximaciones con apariencias peculiares. Una de las formas para aproximar la función CDF es calcular lo que se conoce como la función erf (del inglés "error function", función de error), mediante una ecuación llamada A&S 7.1.26, y después usar la función erf para determinar el CDF. El código de la función CDF se puede observar en la Figura 3.
Figura 3. La función CDF en C#
static double CumDensity(double z)
{
double p = 0.3275911;
double a1 = 0.254829592;
double a2 = -0.284496736;
double a3 = 1.421413741;
double a4 = -1.453152027;
double a5 = 1.061405429;
int sign;
if (z < 0.0)
sign = -1;
else
sign = 1;
double x = Math.Abs(z) / Math.Sqrt(2.0);
double t = 1.0 / (1.0 + p * x);
double erf = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) *
t * Math.Exp(-x * x);
return 0.5 * (1.0 + (sign * erf));
}
El segundo reto al escribir código de clasificación probit consiste en determinar los valores de las ponderaciones, de forma que cuando se utilicen los datos de entrenamiento, los valores de salida calculados coincidan lo máximo posible con los valores de salida conocidos. Otra forma de abordar el problema es marcar como objetivo la minimización del error entre los valores de salida computado y conocido. Esto se conoce como entrenar el modelo usando optimización numérica.
No hay una forma fácil de entrenar la mayoría de clasificadores de aprendizaje automático, incluidos los clasificadores probit. Hay aproximadamente una docena de técnicas principales que puede usar, cada una con docenas de variaciones. Las técnicas de entrenamiento comunes incluyen gradiente descendente simple, propagación hacia atrás, Newton-Raphson, optimización por enjambre de partículas, optimización evolutiva y L-BFGS. El programa de demostración usa una de las técnicas de entrenamiento más antiguas y simples: la optimización simplex.
Explicación de la optimización simplex
En términos generales, podríamos decir que un simplex es un triángulo. La idea detrás de la optimización simplex es comenzar con tres posibles soluciones (de ahí, "simplex"). Una solución será la "mejor" (best, la que tiene el menor error), otra será la "peor" (worst, la del mayor error) y la tercera se conoce como la "otra" (other). A continuación, la optimización simplex crea tres nuevas soluciones potenciales llamadas "expandida" (expanded), "reflejada" (reflected) y "contraída" (contracted). Cada una de estas se comparan con la peor solución actual y, si alguna de las nuevas candidatas es mejor (tiene un menor error), se sustituye la peor solución.
La optimización simplex se ilustra en la Figura 4. En un caso simple en el que la solución consiste en dos valores, como (1,23; 4,56), puede pensar en una solución como un punto en el plano (x, y). En la parte izquierda de la Figura 4 se muestra cómo se generan tres nuevas soluciones candidatas a partir de las soluciones mejor, peor y "otra" actuales.
Figura 4. La optimización simplex
En primer lugar, se calcula un centroide. El centroide es la media de las soluciones mejor y otra. En dos dimensiones, se trata de un punto a mitad de camino entre los puntos de mejor y de otra. A continuación, se crea una línea imaginaria que comienza en el punto peor y se extiende a través del centroide. El candidato contraído se encuentra entre los puntos peor y el centroide. El candidato reflejado se encuentra en la línea imaginaria, más allá del centroide. Y el candidato expandido se encuentra más allá del punto reflejado.
En cada iteración de la optimización simplex, si uno de los candidatos expandido, reflejado o contraído es mejor que la peor solución actual, peor se reemplaza por ese candidato. Si ninguno de los tres candidatos generados son mejores que la peor solución, la peor solución actual y las otras soluciones se mueven hacia la mejor solución a puntos intermedios que se encuentran entre su posición actual y la mejor solución, tal y como se muestra en la parte derecha de la Figura 4.
Después de cada iteración, se crea un nuevo triángulo virtual "mejor-otra-peor", que se acerca más y más a una solución óptima. Si se toma una instantánea de cada triángulo y se observa de forma secuencial, el triángulo en movimiento se parece a una masa puntiaguda que se mueve a lo largo del plano de una forma que recuerda a una ameba unicelular. Por este motivo, a la optimización simplex a veces se le llama optimización por el método ameba.
Hay muchas variaciones de la optimización simplex, que difieren en la distancia a la que se encuentran las soluciones contraída, reflejada y expandida respecto del centroide actual, y en el orden en que se comprueban las soluciones candidatas para ver si son mejores que la peor solución actual. La forma más común de la optimización simplex se conoce como el algoritmo Nelder-Mead. El programa de demostración usa una variación más simple que no tiene un nombre específico.
Para la clasificación probit, cada solución potencial es un conjunto de valores de ponderación. En la Figura 5 se muestra, en pseudocódigo, la variación de la optimización simplex que se utiliza en el programa de demostración.
Figura 5. Pseudocódigo para la optimización simplex que se utiliza en el programa de demostración
randomly initialize best, worst other solutions
loop maxEpochs times
create centroid from worst and other
create expanded
if expanded is better than worst, replace worst with expanded,
continue loop
create reflected
if reflected is better than worst, replace worst with reflected,
continue loop
create contracted
if contracted is better than worst, replace worst with contracted,
continue loop
create a random solution
if random solution is better than worst, replace worst,
continue loop
shrink worst and other toward best
end loop
return best solution found
La optimización simplex, como todos los demás algoritmos de optimización por aprendizaje automático, tiene sus ventajas y sus inconvenientes. Sin embargo, es (relativamente) simple de implementar y normalmente (aunque no siempre) ofrece buenos resultados en la práctica.
El programa de demostración
Para crear el programa de demostración, inicié Visual Studio, seleccioné la plantilla de programa de aplicación de consola y la denominé ProbitClassification. La demostración no tiene dependencias significativas de la versión de Microsoft .NET Framework, por lo que debería servir cualquier versión relativamente reciente de Visual Studio. Cuando se cargó el código de la plantilla, en la ventana del Explorador de soluciones cambié el nombre del archivo Program.cs por ProbitProgram.cs y Visual Studio cambió automáticamente el nombre de la clase Program.
Figura 6. Comienzo del código de demostración
using System;
namespace ProbitClassification
{
class ProbitProgram
{
static void Main(string[] args)
{
Console.WriteLine("\nBegin Probit Binary Classification demo");
Console.WriteLine("Goal is to predict death (0 = false, 1 = true)");
double[][] data = new double[30][];
data[0] = new double[] { 48, +1, 4.40, 0 };
data[1] = new double[] { 60, -1, 7.89, 1 };
// Etc.
data[29] = new double[] { 68, -1, 8.38, 1 };
...
El comienzo del código de demostración se muestra en la Figura 6. Los datos ficticios se encuentran codificados de forma rígida dentro del programa. En una situación que no fuera una demostración, sus datos se almacenarían en un archivo de texto y tendría que escribir un método de utilidad para cargar los datos en memoria. A continuación, los datos de origen se muestran mediante el método auxiliar definido por el programa ShowData:
Console.WriteLine("\nRaw data: \n");
Console.WriteLine(" Age Sex Kidney Died");
Console.WriteLine("=======================================");
ShowData(data, 5, 2, true);
Después, se normalizan las columnas 0 y 2 de los datos de origen:
Console.WriteLine("Normalizing age and kidney data");
int[] columns = new int[] { 0, 2 };
double[][] means = Normalize(data, columns); // Normalize, save means & stdDevs
Console.WriteLine("Done");
Console.WriteLine("\nNormalized data: \n");
ShowData(data, 5, 2, true);
El método Normalize almacena y devuelve las desviaciones medias y típicas de todas las columnas de forma que, cuando se encuentran nuevos datos, se pueden normalizar usando los mismos parámetros que se utilizaron para entrenar el modelo. A continuación, los datos normalizados se dividen en un conjunto de entrenamiento (80 %) y un conjunto de pruebas (20 %):
Console.WriteLine("Creating train (80%) and test (20%) matrices");
double[][] trainData;
double[][] testData;
MakeTrainTest(data, 0, out trainData, out testData);
Console.WriteLine("Done");
Console.WriteLine("\nNormalized training data: \n");
ShowData(trainData, 3, 2, true);
Podría querer parametrizar el método MakeTrainTest para aceptar el porcentaje de elementos que se incluirán en el conjunto de entrenamiento. Después, se crea una instancia de un objeto clasificador probit definido por el programa:
int numFeatures = 3; // Age, sex, kidney
Console.WriteLine("Creating probit binary classifier");
ProbitClassifier pc = new ProbitClassifier(numFeatures);
Y entonces, se entrena el clasificador probit mediante una optimización simplex para encontrar los valores de las ponderaciones, de forma que los valores de salida calculados se aproximen mucho a los valores de salida conocidos:
int maxEpochs = 100; // 100 gives a representative demo
Console.WriteLine("Setting maxEpochs = " + maxEpochs);
Console.WriteLine("Starting training");
double[] bestWeights = pc.Train(trainData, maxEpochs, 0);
Console.WriteLine("Training complete");
Console.WriteLine("\nBest weights found:");
ShowVector(bestWeights, 4, true);
El programa de demostración concluye con el cálculo de la precisión de clasificación del modelo en los datos de entrenamiento y en los datos de prueba:
...
double testAccuracy = pc.Accuracy(testData, bestWeights);
Console.WriteLine("Prediction accuracy on test data =
" + testAccuracy.ToString("F4"));
Console.WriteLine("\nEnd probit binary classification demo\n");
Console.ReadLine();
} // Main
La demostración no hace una predicción de datos que no se hayan visto previamente. Así se llevaría a cabo una predicción:
// Slightly older, male, higher kidney score
double[] unknownNormalized = new double[] { 0.25, -1.0, 0.50 };
int died = pc.ComputeDependent(unknownNormalized, bestWeights);
if (died == 0)
Console.WriteLine("Predict survive");
else if (died == 1)
Console.WriteLine("Predict die");
Este código supone que se han normalizado las variables x independientes (puntuación de age, sex y kidney) mediante las desviaciones típicas y medias procedentes del proceso de normalización de los datos de entrenamiento.
La clase ProbitClassifier
La estructura general de la clase ProbitClassifier se muestra en la Figura 7. La definición de ProbitClassifier contiene una clase anidada que se llama Solution. Esa subclase deriva de la interfaz IComparable, de forma que se puede ordenar automáticamente una matriz de tres objetos Solution, para obtener las soluciones mejor, otra y peor. Normalmente, no soy partidario de las técnicas de codificación con muchas florituras, pero en esta situación el beneficio compensa ligeramente la complejidad agregada.
Figura 7. La clase ProbitClassifier
public class ProbitClassifier
{
private int numFeatures; // Number of independent variables
private double[] weights; // b0 = constant
private Random rnd;
public ProbitClassifier(int numFeatures) { . . }
public double[] Train(double[][] trainData, int maxEpochs, int seed) { . . }
private double[] Expanded(double[] centroid, double[] worst) { . . }
private double[] Contracted(double[] centroid, double[] worst) { . . }
private double[] RandomSolution() { . . }
private double Error(double[][] trainData, double[] weights) { . . }
public void SetWeights(double[] weights) { . . }
public double[] GetWeights() { . . }
public double ComputeOutput(double[] dataItem, double[] weights) { . . }
private static double CumDensity(double z) { . . }
public int ComputeDependent(double[] dataItem, double[] weights) { . . }
public double Accuracy(double[][] trainData, double[] weights) { . . }
private class Solution : IComparable<Solution>
{
// Defined here
}
}
La clase ProbitClassifier tiene dos métodos de salida. El método ComputeOutput devuelve un valor entre 0,0 y 1,0 y se usa durante el entrenamiento para calcular un valor de error. El método ComputeDependent es un contenedor alrededor de ComputeOutput y devuelve 0 si la salida es menor o igual a 0,5, o 1 si la salida es mayor de 0,5. Estos valores devueltos se utilizan para calcular la precisión.
Resumen
La clasificación probit es una de las técnicas de aprendizaje automático más antiguas. Dado que la clasificación probit es muy similar a la clasificación por regresión logística, en la práctica es común es usar indistintamente cualquiera de las dos técnicas. Puesto que la regresión logística es más fácil de calcular que probit, la clasificación probit se usa con menos frecuencia y, con el tiempo, se ha convertido en cierto modo en un ciudadano de segundo nivel del aprendizaje automático. No obstante, la clasificación probit es a menudo muy efectiva y puede ser un añadido muy valioso para su kit de herramientas de aprendizaje automático.
El Dr. James McCaffrey trabaja para Microsoft Research en Redmond, Wash. Ha trabajado en gran cantidad de productos de Microsoft, como Internet Explorer y Bing. Es posible contactar con el Dr. McCaffrey en la dirección de correo jammc@microsoft.com.
Gracias a los siguientes expertos técnicos de Microsoft Research por su ayuda en la revisión de este artículo: Nathan Brown y Kirk Olynyk.