¿Cómo hacer TDD para algo con muchas permutaciones?

15

Al crear un sistema como una IA, que puede tomar muchas rutas diferentes muy rápidamente, o realmente cualquier algoritmo que tenga varias entradas diferentes, el posible conjunto de resultados puede contener una gran cantidad de permutaciones.

¿Qué enfoque se debe tomar para usar TDD al crear un sistema que genera muchas, muchas permutaciones diferentes de resultados?

Nicole
fuente
1
La bondad general del sistema de inteligencia artificial generalmente se mide mediante la prueba de recuperación de precisión con un conjunto de entrada de referencia. Esta prueba está aproximadamente a la par con las "pruebas de integración". Como otros han mencionado, es más como "investigación de algoritmos basados ​​en pruebas" que como " diseño basado en pruebas ".
rwong
Defina lo que quiere decir con "AI". Es un campo de estudio más que cualquier tipo particular de programa. Para ciertas implementaciones de IA, generalmente no puede probar algunos tipos de cosas (es decir, comportamiento emergente) a través de TDD.
Steven Evers
@SnOrfus Lo digo en el sentido más general, rudimentario, una máquina de toma de decisiones.
Nicole

Respuestas:

7

Tomando un enfoque más práctico para la respuesta de pdr . TDD tiene que ver con el diseño de software más que con las pruebas. Utiliza pruebas unitarias para verificar su trabajo a medida que avanza.

Entonces, en un nivel de prueba de unidad, debe diseñar las unidades para que puedan probarse de una manera completamente determinista. Puede hacer esto tomando cualquier cosa que haga que la unidad no sea determinista (como un generador de números aleatorios) y abstraiga eso. Digamos que tenemos un ejemplo ingenuo de un método para decidir si un movimiento es bueno o no:

class Decider {

  public boolean decide(float input, float risk) {

      float inputRand = Math.random();
      if (inputRand > input) {
         float riskRand = Math.random();
      }
      return false;

  }

}

// The usage:
Decider d = new Decider();
d.decide(0.1337f, 0.1337f);

Este método es muy difícil de probar y lo único que realmente puede verificar en las pruebas unitarias son sus límites ... pero eso requiere muchos intentos para llegar a los límites. Por lo tanto, abstraigamos la parte aleatoria creando una interfaz y una clase concreta que envuelva la funcionalidad:

public interface IRandom {

   public float random();

}

public class ConcreteRandom implements IRandom {

   public float random() {
      return Math.random();
   }

}

La Deciderclase ahora necesita usar la clase concreta a través de su abstracción, es decir, la interfaz. Esta forma de hacer las cosas se llama inyección de dependencia (el ejemplo a continuación es un ejemplo de inyección de constructor, pero también puede hacerlo con un setter):

class Decider {

  IRandom irandom;

  public Decider(IRandom irandom) { // constructor injection
      this.irandom = irandom;
  }

  public boolean decide(float input, float risk) {

      float inputRand = irandom.random();
      if (inputRand > input) {
         float riskRand = irandom.random();
      }
      return false;

  }

}

// The usage:
Decider d = new Decider(new ConcreteRandom);
d.decide(0.1337f, 0.1337f);

Puede preguntarse por qué es necesaria esta "acumulación de código". Bueno, para empezar, ahora puede burlarse del comportamiento de la parte aleatoria del algoritmo porque Deciderahora tiene una dependencia que sigue el IRandom"contrato" de s. Puede usar un marco de imitación para esto, pero este ejemplo es lo suficientemente simple como para codificarse:

class MockedRandom() implements IRandom {

    public List<Float> floats = new ArrayList<Float>();
    int pos;

   public void addFloat(float f) {
     floats.add(f);
   }

   public float random() {
      float out = floats.get(pos);
      if (pos != floats.size()) {
         pos++;
      }
      return out;
   }

}

La mejor parte es que esto puede reemplazar completamente la implementación concreta "real". El código se vuelve fácil de probar así:

@Before void setUp() {
  MockedRandom mRandom = new MockedRandom();

  Decider decider = new Decider(mRandom);
}

@Test
public void testDecisionWithLowInput_ShouldGiveFalse() {

  mRandom.addFloat(0f);

  assertFalse(decider.decide(0.1337f, 0.1337f));
}

@Test
public void testDecisionWithHighInputRandButLowRiskRand_ShouldGiveFalse() {

  mRandom.addFloat(1f);
  mRandom.addFloat(0f);

  assertFalse(decider.decide(0.1337f, 0.1337f));
}

@Test
public void testDecisionWithHighInputRandAndHighRiskRand_ShouldGiveTrue() {

  mRandom.addFloat(1f);
  mRandom.addFloat(1f);

  assertTrue(decider.decide(0.1337f, 0.1337f));
}

Espero que esto le brinde ideas sobre cómo diseñar su aplicación para que las permutaciones se puedan forzar para que pueda probar todos los casos límite y demás.

Spoike
fuente
3

El TDD estricto tiende a descomponerse un poco para sistemas más complejos, pero eso no importa demasiado en términos prácticos: una vez que vaya más allá de poder aislar entradas individuales, simplemente elija algunos casos de prueba que brinden una cobertura razonable y utilícelos.

Esto requiere cierto conocimiento de lo que la implementación va a hacer bien, pero eso es más una preocupación teórica: es muy poco probable que esté construyendo una IA que fue especificada en detalle por usuarios no técnicos. Está en la misma categoría que pasar las pruebas al codificar los casos de prueba: oficialmente, la prueba es la especificación y la implementación es correcta y la solución más rápida posible, pero en realidad nunca sucede.

Tom Clarkson
fuente
2

TDD no se trata de pruebas, se trata de diseño.

Lejos de desmoronarse con la complejidad, sobresale en estas circunstancias. Lo llevará a considerar el problema más grande en piezas más pequeñas, lo que conducirá a un mejor diseño.

No intente probar cada permutación de su algoritmo. Simplemente cree prueba tras prueba, escriba el código más simple para que la prueba funcione, hasta que tenga cubiertas sus bases. Debería ver a qué me refiero con analizar el problema porque se lo alentará a simular partes del problema mientras prueba otras partes, para evitar tener que escribir 10 mil millones de pruebas por 10 mil millones de permutaciones.

Editar: quería agregar un ejemplo, pero no tuve tiempo antes.

Consideremos un algoritmo de clasificación in situ. Podríamos seguir adelante y escribir pruebas que cubran el extremo superior de la matriz, el extremo inferior de la matriz y todo tipo de combinaciones extrañas en el medio. Para cada uno, tendríamos que construir una matriz completa de algún tipo de objeto. Esto llevaría tiempo.

O podríamos abordar el problema en cuatro partes:

  1. Atraviesa la matriz.
  2. Compara los artículos seleccionados.
  3. Cambiar elementos.
  4. Coordina los tres anteriores.

La primera es la única parte complicada del problema, pero al abstraerlo del resto, lo ha simplificado mucho.

El segundo es casi seguro manejado por el objeto mismo, al menos opcionalmente, en muchos marcos de tipo estático habrá una interfaz para mostrar si esa funcionalidad está implementada. Entonces no necesitas probar esto.

El tercero es increíblemente fácil de probar.

El cuarto solo maneja dos punteros, le pide a la clase transversal que mueva los punteros, solicita una comparación y, en función del resultado de esa comparación, pide que se intercambien los elementos. Si has fingido los primeros tres problemas, puedes probar esto muy fácilmente.

¿Cómo hemos llevado a un mejor diseño aquí? Digamos que lo has mantenido simple e implementado un tipo de burbuja. Funciona pero, cuando vas a producción y tiene que manejar un millón de objetos, es demasiado lento. Todo lo que tiene que hacer es escribir una nueva funcionalidad transversal e intercambiarla. No tiene que lidiar con la complejidad de manejar los otros tres problemas.

Esto, encontrará, es la diferencia entre las pruebas unitarias y TDD. El probador de unidades dirá que esto ha hecho que sus pruebas sean frágiles, que si hubiera probado entradas y salidas simples, ahora no tendría que escribir más pruebas para su nueva funcionalidad. El TDDer dirá que he separado las preocupaciones adecuadamente para que cada clase que tengo haga una cosa y una cosa bien.

pdr
fuente
1

No es posible probar cada permutación de un cálculo con muchas variables. Pero eso no es nada nuevo, siempre ha sido cierto para cualquier programa por encima de la complejidad del juguete. El objetivo de las pruebas es verificar la propiedad del cálculo. Por ejemplo, ordenar una lista con 1000 números requiere un poco de esfuerzo, pero cualquier solución individual se puede verificar muy fácilmente. Ahora, aunque hay 1000! posibles (clases de) entradas para ese programa y no puede probarlas todas, es completamente suficiente generar solo 1000 entradas al azar y verificar que la salida está, de hecho, ordenada. ¿Por qué? Debido a que es casi imposible escribir un programa que clasifique de manera confiable 1000 vectores generados aleatoriamente sin ser también correctos en general (a menos que lo manipules deliberadamente para manipular ciertas entradas mágicas ...)

Ahora, en general, las cosas son un poco más complicadas. Realmente ha habido errores en los que un remitente no enviaba correos electrónicos a los usuarios si tenían una 'f' en su nombre de usuario y el día de la semana es el viernes. Pero considero que es un esfuerzo desperdiciado tratar de anticipar tal rareza. Su conjunto de pruebas debe proporcionarle una confianza constante de que el sistema hace lo que espera en las entradas que espera. Si hace cosas funky en ciertos casos funky, lo notará lo suficientemente pronto después de probar el primer caso funky, y luego puede escribir una prueba específicamente para ese caso (que generalmente también cubrirá una clase completa de casos similares).

Kilian Foth
fuente
Dado que genera 1000 entradas al azar, ¿cómo prueba las salidas? Seguramente dicha prueba implicará algo de lógica, que en sí misma no se prueba. ¿Entonces pruebas la prueba? ¿Cómo? El punto es que debe probar la lógica usando transiciones de estado: dada la entrada X, la salida debe ser Y. Una prueba que involucra lógica es propensa a errores tanto como la lógica que prueba. En términos lógicos, justificar un argumento con otro argumento lo coloca en el camino de regresión escéptico: debe hacer algunas afirmaciones. Estas afirmaciones son tus pruebas.
Izhaki
0

Tome los casos extremos más alguna entrada aleatoria.

Para tomar el ejemplo de clasificación:

  • Ordenar algunas listas aleatorias
  • Tome una lista que ya está ordenada
  • Tome una lista que esté en orden inverso
  • Toma una lista que está casi ordenada

Si funciona rápido para estos, puede estar seguro de que funcionará para todas las entradas.

Carra
fuente