Ejemplo de optimización restringida de TensorFlow con el conjunto de datos CelebA

Ver en TensorFlow.org Ejecutar en Google Colab Ver en GitHub Descargar cuaderno

Este cuaderno muestra una manera fácil de crear y optimizar problemas restringidos utilizando la biblioteca TFCO. Este método puede ser útil para mejorar los modelos cuando descubrimos que no están funcionando igual de bien en diferentes segmentos de nuestros datos, que podemos identificar mediante los indicadores de equidad . El segundo de los principios de inteligencia artificial de Google establece que nuestra tecnología debe evitar crear o reforzar sesgos injustos, y creemos que esta técnica puede ayudar a mejorar la equidad del modelo en algunas situaciones. En particular, este portátil:

  • Entrene un modelo de red neuronal simple y sin restricciones para detectar la sonrisa de una persona en imágenes utilizando tf.keras y el conjunto de datos de atributos CelebFaces ( CelebA ) a gran escala.
  • Evalúe el rendimiento del modelo en comparación con una métrica de equidad de uso común en todos los grupos de edad, utilizando indicadores de equidad.
  • Establezca un problema de optimización restringido simple para lograr un rendimiento más justo en todos los grupos de edad.
  • Vuelva a entrenar el modelo ahora restringido y evalúe el rendimiento nuevamente, asegurándose de que nuestra métrica de equidad elegida haya mejorado.

Última actualización: 3/11 Feb 2020

Instalación

Este cuaderno se creó en Colaboratory , conectado al backend de Python 3 Google Compute Engine. Si desea alojar este portátil en un entorno diferente, no debería experimentar ningún problema importante siempre que incluya todos los paquetes necesarios en las celdas siguientes.

Tenga en cuenta que la primera vez que ejecute las instalaciones de pip, es posible que se le solicite que reinicie el tiempo de ejecución debido a los paquetes preinstalados desactualizados. Una vez que lo haga, se utilizarán los paquetes correctos.

Pip instala

Tenga en cuenta que, dependiendo de cuándo ejecute la celda a continuación, es posible que reciba una advertencia sobre la versión predeterminada de TensorFlow en Colab que cambiará pronto a TensorFlow 2.X. Puede ignorar con seguridad esa advertencia, ya que este portátil fue diseñado para ser compatible con TensorFlow 1.X y 2.X.

Importar módulos

Además, agregamos algunas importaciones que son específicas de los indicadores de equidad que usaremos para evaluar y visualizar el desempeño del modelo.

Aunque TFCO es compatible con la ejecución ansiosa y de gráficos, este cuaderno asume que la ejecución ansiosa está habilitada de forma predeterminada como en TensorFlow 2.x. Para asegurarse de que nada se rompa, se habilitará la ejecución ansiosa en la celda a continuación.

Habilite versiones de ejecución e impresión ávidas

Eager execution enabled by default.
TensorFlow 2.4.1
TFMA 0.29.0
TFDS 4.2.0
FI 0.29.0

CelebA conjunto de datos

CelebA es un conjunto de datos de atributos faciales a gran escala con más de 200,000 imágenes de celebridades, cada una con 40 anotaciones de atributos (como tipo de cabello, accesorios de moda, rasgos faciales, etc.) y 5 lugares emblemáticos (posiciones de ojos, boca y nariz). Para obtener más detalles, consulte el documento . Con el permiso de los propietarios, hemos almacenado este conjunto de datos en Google Cloud Storage y accedemos a él principalmente a través de TensorFlow Datasets ( tfds ) .

En este cuaderno:

  • Nuestro modelo intentará clasificar si el sujeto de la imagen está sonriendo, representado por el atributo "Sonriendo" * .
  • Las imágenes cambiarán de tamaño de 218x178 a 28x28 para reducir el tiempo de ejecución y la memoria durante el entrenamiento.
  • El desempeño de nuestro modelo se evaluará en todos los grupos de edad, utilizando el atributo binario "Joven". Llamaremos a este "grupo de edad" en este cuaderno.

* Si bien hay poca información disponible sobre la metodología de etiquetado para este conjunto de datos, asumiremos que el atributo "Sonriendo" fue determinado por una expresión complacida, amable o divertida en el rostro del sujeto. Para el propósito de este estudio de caso, tomaremos estas etiquetas como verdad fundamental.

gcs_base_dir = "gs://celeb_a_dataset/"
celeb_a_builder = tfds.builder("celeb_a", data_dir=gcs_base_dir, version='2.0.0')

celeb_a_builder.download_and_prepare()

num_test_shards_dict = {'0.3.0': 4, '2.0.0': 2} # Used because we download the test dataset separately
version = str(celeb_a_builder.info.version)
print('Celeb_A dataset version: %s' % version)
Celeb_A dataset version: 2.0.0

Probar funciones auxiliares del conjunto de datos

Advertencias

Antes de seguir adelante, hay varias consideraciones a tener en cuenta al usar CelebA:

  • Aunque en principio este cuaderno podría utilizar cualquier conjunto de datos de imágenes faciales, se eligió CelebA porque contiene imágenes de dominio público de figuras públicas.
  • Todas las anotaciones de atributos en CelebA se operacionalizan como categorías binarias. Por ejemplo, el atributo "Joven" (según lo determinado por los etiquetadores del conjunto de datos) se indica como presente o ausente en la imagen.
  • Las categorizaciones de CelebA no reflejan la diversidad humana real de atributos.
  • A los efectos de este cuaderno, la función que contiene el atributo "Joven" se denomina "grupo de edad", donde la presencia del atributo "Joven" en una imagen se etiqueta como miembro del grupo de edad "Joven" y la ausencia del atributo "Joven" se etiqueta como miembro del grupo de edad "No joven". Estas son suposiciones hechas ya que esta información no se menciona en el documento original .
  • Como tal, el rendimiento en los modelos entrenados en este cuaderno está ligado a las formas en que los autores de CelebA han operacionalizado y anotado los atributos.
  • Este modelo no debe utilizarse con fines comerciales, ya que eso violaría el acuerdo de investigación no comercial de CelebA .

Configuración de funciones de entrada

Las celdas siguientes ayudarán a optimizar la canalización de entrada y a visualizar el rendimiento.

Primero definimos algunas variables relacionadas con los datos y definimos una función de preprocesamiento requerida.

Definir variables

Definir funciones de preprocesamiento

Luego, construimos las funciones de datos que necesitamos en el resto del colab.

# Train data returning either 2 or 3 elements (the third element being the group)
def celeb_a_train_data_wo_group(batch_size):
  celeb_a_train_data = celeb_a_builder.as_dataset(split='train').shuffle(1024).repeat().batch(batch_size).map(preprocess_input_dict)
  return celeb_a_train_data.map(get_image_and_label)
def celeb_a_train_data_w_group(batch_size):
  celeb_a_train_data = celeb_a_builder.as_dataset(split='train').shuffle(1024).repeat().batch(batch_size).map(preprocess_input_dict)
  return celeb_a_train_data.map(get_image_label_and_group)

# Test data for the overall evaluation
celeb_a_test_data = celeb_a_builder.as_dataset(split='test').batch(1).map(preprocess_input_dict).map(get_image_label_and_group)
# Copy test data locally to be able to read it into tfma
copy_test_files_to_local()

Construya un modelo DNN simple

Debido a que este cuaderno se enfoca en TFCO, ensamblaremos un modelo tf.keras.Sequential simple y sin tf.keras.Sequential .

Es posible que podamos mejorar en gran medida el rendimiento del modelo agregando algo de complejidad (por ejemplo, capas más densamente conectadas, explorar diferentes funciones de activación, aumentar el tamaño de la imagen), pero eso puede distraernos del objetivo de demostrar lo fácil que es aplicar la biblioteca TFCO. al trabajar con Keras. Por esa razón, el modelo se mantendrá simple, pero siéntase animado a explorar este espacio.

def create_model():
  # For this notebook, accuracy will be used to evaluate performance.
  METRICS = [
    tf.keras.metrics.BinaryAccuracy(name='accuracy')
  ]

  # The model consists of:
  # 1. An input layer that represents the 28x28x3 image flatten.
  # 2. A fully connected layer with 64 units activated by a ReLU function.
  # 3. A single-unit readout layer to output real-scores instead of probabilities.
  model = keras.Sequential([
      keras.layers.Flatten(input_shape=(IMAGE_SIZE, IMAGE_SIZE, 3), name='image'),
      keras.layers.Dense(64, activation='relu'),
      keras.layers.Dense(1, activation=None)
  ])

  # TFCO by default uses hinge loss — and that will also be used in the model.
  model.compile(
      optimizer=tf.keras.optimizers.Adam(0.001),
      loss='hinge',
      metrics=METRICS)
  return model

También definimos una función para establecer semillas para garantizar resultados reproducibles. Tenga en cuenta que este colab está destinado a ser una herramienta educativa y no tiene la estabilidad de una tubería de producción finamente ajustada. Ejecutar sin sembrar una semilla puede producir resultados variados.

def set_seeds():
  np.random.seed(121212)
  tf.compat.v1.set_random_seed(212121)

Funciones auxiliares de los indicadores de equidad

Antes de entrenar nuestro modelo, definimos una serie de funciones auxiliares que nos permitirán evaluar el desempeño del modelo a través de indicadores de equidad.

Primero, creamos una función auxiliar para guardar nuestro modelo una vez que lo entrenamos.

def save_model(model, subdir):
  base_dir = tempfile.mkdtemp(prefix='saved_models')
  model_location = os.path.join(base_dir, subdir)
  model.save(model_location, save_format='tf')
  return model_location

A continuación, definimos funciones utilizadas para preprocesar los datos con el fin de pasarlos correctamente a TFMA.

Funciones de preprocesamiento de datos para

Finalmente, definimos una función que evalúa los resultados en TFMA.

def get_eval_results(model_location, eval_subdir):
  base_dir = tempfile.mkdtemp(prefix='saved_eval_results')
  tfma_eval_result_path = os.path.join(base_dir, eval_subdir)

  eval_config_pbtxt = """
        model_specs {
          label_key: "%s"
        }
        metrics_specs {
          metrics {
            class_name: "FairnessIndicators"
            config: '{ "thresholds": [0.22, 0.5, 0.75] }'
          }
          metrics {
            class_name: "ExampleCount"
          }
        }
        slicing_specs {}
        slicing_specs { feature_keys: "%s" }
        options {
          compute_confidence_intervals { value: False }
          disabled_outputs{values: "analysis"}
        }
      """ % (LABEL_KEY, GROUP_KEY)

  eval_config = text_format.Parse(eval_config_pbtxt, tfma.EvalConfig())

  eval_shared_model = tfma.default_eval_shared_model(
        eval_saved_model_path=model_location, tags=[tf.saved_model.SERVING])

  schema_pbtxt = """
        tensor_representation_group {
          key: ""
          value {
            tensor_representation {
              key: "%s"
              value {
                dense_tensor {
                  column_name: "%s"
                  shape {
                    dim { size: 28 }
                    dim { size: 28 }
                    dim { size: 3 }
                  }
                }
              }
            }
          }
        }
        feature {
          name: "%s"
          type: FLOAT
        }
        feature {
          name: "%s"
          type: FLOAT
        }
        feature {
          name: "%s"
          type: BYTES
        }
        """ % (IMAGE_KEY, IMAGE_KEY, IMAGE_KEY, LABEL_KEY, GROUP_KEY)
  schema = text_format.Parse(schema_pbtxt, schema_pb2.Schema())
  coder = tf_example_record.TFExampleBeamRecord(
      physical_format='inmem', schema=schema,
      raw_record_column_name=tfma.ARROW_INPUT_COLUMN)
  tensor_adapter_config = tensor_adapter.TensorAdapterConfig(
    arrow_schema=coder.ArrowSchema(),
    tensor_representations=coder.TensorRepresentations())
  # Run the fairness evaluation.
  with beam.Pipeline() as pipeline:
    _ = (
          tfds_as_pcollection(pipeline, 'celeb_a', 'test')
          | 'ExamplesToRecordBatch' >> coder.BeamSource()
          | 'ExtractEvaluateAndWriteResults' >>
          tfma.ExtractEvaluateAndWriteResults(
              eval_config=eval_config,
              eval_shared_model=eval_shared_model,
              output_path=tfma_eval_result_path,
              tensor_adapter_config=tensor_adapter_config)
    )
  return tfma.load_eval_result(output_path=tfma_eval_result_path)

Entrenar y evaluar modelo sin restricciones

Con el modelo ahora definido y la canalización de entrada en su lugar, ahora estamos listos para entrenar nuestro modelo. Para reducir la cantidad de tiempo de ejecución y memoria, entrenaremos el modelo dividiendo los datos en pequeños lotes con solo unas pocas iteraciones repetidas.

Tenga en cuenta que ejecutar este notebook en TensorFlow <2.0.0 puede np.where una advertencia de np.where para np.where . Ignore con seguridad esta advertencia, ya que TensorFlow aborda esto en 2.X mediante el uso de tf.where en lugar de np.where .

BATCH_SIZE = 32

# Set seeds to get reproducible results
set_seeds()

model_unconstrained = create_model()
model_unconstrained.fit(celeb_a_train_data_wo_group(BATCH_SIZE), epochs=5, steps_per_epoch=1000)
Epoch 1/5
1000/1000 [==============================] - 17s 11ms/step - loss: 0.6219 - accuracy: 0.7189
Epoch 2/5
1000/1000 [==============================] - 10s 10ms/step - loss: 0.4061 - accuracy: 0.8187
Epoch 3/5
1000/1000 [==============================] - 10s 10ms/step - loss: 0.3649 - accuracy: 0.8391
Epoch 4/5
1000/1000 [==============================] - 16s 16ms/step - loss: 0.3427 - accuracy: 0.8485
Epoch 5/5
1000/1000 [==============================] - 10s 10ms/step - loss: 0.3390 - accuracy: 0.8482
<tensorflow.python.keras.callbacks.History at 0x7f47c01a8550>

La evaluación del modelo en los datos de la prueba debería dar como resultado una puntuación de precisión final de poco más del 85%. No está mal para un modelo simple sin ajustes precisos.

print('Overall Results, Unconstrained')
celeb_a_test_data = celeb_a_builder.as_dataset(split='test').batch(1).map(preprocess_input_dict).map(get_image_label_and_group)
results = model_unconstrained.evaluate(celeb_a_test_data)
Overall Results, Unconstrained
19962/19962 [==============================] - 40s 2ms/step - loss: 0.2125 - accuracy: 0.8636

Sin embargo, el desempeño evaluado en todos los grupos de edad puede revelar algunas deficiencias.

Para explorar esto más a fondo, evaluamos el modelo con indicadores de equidad (a través de TFMA). En particular, estamos interesados ​​en ver si existe una brecha significativa en el desempeño entre las categorías "Joven" y "No joven" cuando se evalúa según la tasa de falsos positivos.

Se produce un error de falso positivo cuando el modelo predice incorrectamente la clase positiva. En este contexto, se produce un resultado falso positivo cuando la verdad básica es una imagen de una celebridad "No sonriendo" y el modelo predice "Sonriendo". Por extensión, la tasa de falsos positivos, que se utiliza en la visualización anterior, es una medida de precisión para una prueba. Si bien este es un error relativamente mundano en este contexto, los errores de falso positivo a veces pueden causar comportamientos más problemáticos. Por ejemplo, un error falso positivo en un clasificador de spam podría hacer que un usuario pierda un correo electrónico importante.

model_location = save_model(model_unconstrained, 'model_export_unconstrained')
eval_results_unconstrained = get_eval_results(model_location, 'eval_results_unconstrained')
INFO:tensorflow:Assets written to: /tmp/saved_modelseqklzviu/model_export_unconstrained/assets
INFO:tensorflow:Assets written to: /tmp/saved_modelseqklzviu/model_export_unconstrained/assets
WARNING:apache_beam.runners.interactive.interactive_environment:Dependencies required for Interactive Beam PCollection visualization are not available, please use: `pip install apache-beam[interactive]` to install necessary dependencies to enable all data visualization features.
WARNING:apache_beam.io.tfrecordio:Couldn't find python-snappy so the implementation of _TFRecordUtil._masked_crc32c is not as fast as it could be.
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.6/site-packages/tensorflow_model_analysis/writers/metrics_plots_and_validations_writer.py:113: tf_record_iterator (from tensorflow.python.lib.io.tf_record) is deprecated and will be removed in a future version.
Instructions for updating:
Use eager execution and: 
`tf.data.TFRecordDataset(path)`
WARNING:tensorflow:From /tmpfs/src/tf_docs_env/lib/python3.6/site-packages/tensorflow_model_analysis/writers/metrics_plots_and_validations_writer.py:113: tf_record_iterator (from tensorflow.python.lib.io.tf_record) is deprecated and will be removed in a future version.
Instructions for updating:
Use eager execution and: 
`tf.data.TFRecordDataset(path)`

Como se mencionó anteriormente, nos estamos concentrando en la tasa de falsos positivos. La versión actual de los indicadores de equidad (0.1.2) selecciona la tasa de falsos negativos de forma predeterminada. Después de ejecutar la línea a continuación, anule la selección de false_negative_rate y seleccione false_positive_rate para ver la métrica que nos interesa.

tfma.addons.fairness.view.widget_view.render_fairness_indicator(eval_results_unconstrained)
FairnessIndicatorViewer(slicingMetrics=[{'sliceValue': 'Overall', 'slice': 'Overall', 'metrics': {'example_cou…

Como muestran los resultados anteriores, vemos una brecha desproporcionada entre las categorías "Joven" y "No joven" .

Aquí es donde TFCO puede ayudar al restringir la tasa de falsos positivos para que esté dentro de un criterio más aceptable.

Configuración de modelo restringido

Como se documenta en la biblioteca de TFCO , hay varios ayudantes que facilitarán la limitación del problema:

  1. tfco.rate_context() : esto es lo que se utilizará para construir una restricción para cada categoría de grupo de edad.
  2. tfco.RateMinimizationProblem() : la expresión de tasa que se minimizará aquí será la tasa de falsos positivos sujeta al grupo de edad. En otras palabras, el rendimiento ahora se evaluará en función de la diferencia entre las tasas de falsos positivos del grupo de edad y la del conjunto de datos general. Para esta demostración, se establecerá como restricción una tasa de falsos positivos menor o igual al 5%.
  3. tfco.ProxyLagrangianOptimizerV2() : este es el ayudante que realmente resolverá el problema de restricción de velocidad.

La celda a continuación llamará a estos ayudantes para configurar el entrenamiento del modelo con la restricción de equidad.

# The batch size is needed to create the input, labels and group tensors.
# These tensors are initialized with all 0's. They will eventually be assigned
# the batch content to them. A large batch size is chosen so that there are
# enough number of "Young" and "Not Young" examples in each batch.
set_seeds()
model_constrained = create_model()
BATCH_SIZE = 32

# Create input tensor.
input_tensor = tf.Variable(
    np.zeros((BATCH_SIZE, IMAGE_SIZE, IMAGE_SIZE, 3), dtype="float32"),
    name="input")

# Create labels and group tensors (assuming both labels and groups are binary).
labels_tensor = tf.Variable(
    np.zeros(BATCH_SIZE, dtype="float32"), name="labels")
groups_tensor = tf.Variable(
    np.zeros(BATCH_SIZE, dtype="float32"), name="groups")

# Create a function that returns the applied 'model' to the input tensor
# and generates constrained predictions.
def predictions():
  return model_constrained(input_tensor)

# Create overall context and subsetted context.
# The subsetted context contains subset of examples where group attribute < 1
# (i.e. the subset of "Not Young" celebrity images).
# "groups_tensor < 1" is used instead of "groups_tensor == 0" as the former
# would be a comparison on the tensor value, while the latter would be a
# comparison on the Tensor object.
context = tfco.rate_context(predictions, labels=lambda:labels_tensor)
context_subset = context.subset(lambda:groups_tensor < 1)

# Setup list of constraints.
# In this notebook, the constraint will just be: FPR to less or equal to 5%.
constraints = [tfco.false_positive_rate(context_subset) <= 0.05]

# Setup rate minimization problem: minimize overall error rate s.t. constraints.
problem = tfco.RateMinimizationProblem(tfco.error_rate(context), constraints)

# Create constrained optimizer and obtain train_op.
# Separate optimizers are specified for the objective and constraints
optimizer = tfco.ProxyLagrangianOptimizerV2(
      optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
      constraint_optimizer=tf.keras.optimizers.Adam(learning_rate=0.001),
      num_constraints=problem.num_constraints)

# A list of all trainable variables is also needed to use TFCO.
var_list = (model_constrained.trainable_weights + list(problem.trainable_variables) +
            optimizer.trainable_variables())

El modelo ahora está configurado y listo para ser entrenado con la restricción de tasa de falsos positivos en todo el grupo de edad.

Ahora, debido a que la última iteración del modelo restringido puede no ser necesariamente el modelo de mejor rendimiento en términos de la restricción definida, la biblioteca TFCO viene equipada con tfco.find_best_candidate_index() que puede ayudar a elegir la mejor iteración de las encontradas después de cada época. Piense en tfco.find_best_candidate_index() como una heurística adicional que clasifica cada uno de los resultados según la precisión y la restricción de equidad (en este caso, tasa de falsos positivos en todo el grupo de edad) por separado con respecto a los datos de entrenamiento. De esa manera, puede buscar una mejor compensación entre la precisión general y la restricción de equidad.

Las siguientes celdas iniciarán el entrenamiento con restricciones y, al mismo tiempo, encontrarán el modelo de mejor rendimiento por iteración.

# Obtain train set batches.

NUM_ITERATIONS = 100  # Number of training iterations.
SKIP_ITERATIONS = 10  # Print training stats once in this many iterations.

# Create temp directory for saving snapshots of models.
temp_directory = tempfile.mktemp()
os.mkdir(temp_directory)

# List of objective and constraints across iterations.
objective_list = []
violations_list = []

# Training iterations.
iteration_count = 0
for (image, label, group) in celeb_a_train_data_w_group(BATCH_SIZE):
  # Assign current batch to input, labels and groups tensors.
  input_tensor.assign(image)
  labels_tensor.assign(label)
  groups_tensor.assign(group)

  # Run gradient update.
  optimizer.minimize(problem, var_list=var_list)

  # Record objective and violations.
  objective = problem.objective()
  violations = problem.constraints()

  sys.stdout.write(
      "\r Iteration %d: Hinge Loss = %.3f, Max. Constraint Violation = %.3f"
      % (iteration_count + 1, objective, max(violations)))

  # Snapshot model once in SKIP_ITERATIONS iterations.
  if iteration_count % SKIP_ITERATIONS == 0:
    objective_list.append(objective)
    violations_list.append(violations)

    # Save snapshot of model weights.
    model_constrained.save_weights(
        temp_directory + "/celeb_a_constrained_" +
        str(iteration_count / SKIP_ITERATIONS) + ".h5")

  iteration_count += 1
  if iteration_count >= NUM_ITERATIONS:
    break

# Choose best model from recorded iterates and load that model.
best_index = tfco.find_best_candidate_index(
    np.array(objective_list), np.array(violations_list))

model_constrained.load_weights(
    temp_directory + "/celeb_a_constrained_" + str(best_index) + ".0.h5")

# Remove temp directory.
os.system("rm -r " + temp_directory)
Iteration 100: Hinge Loss = 0.614, Max. Constraint Violation = 0.268
0

Después de haber aplicado la restricción, evaluamos los resultados una vez más utilizando indicadores de equidad.

model_location = save_model(model_constrained, 'model_export_constrained')
eval_result_constrained = get_eval_results(model_location, 'eval_results_constrained')
INFO:tensorflow:Assets written to: /tmp/saved_modelsrnjadh_e/model_export_constrained/assets
INFO:tensorflow:Assets written to: /tmp/saved_modelsrnjadh_e/model_export_constrained/assets

Al igual que la vez anterior, usamos los indicadores de equidad, anule la selección de false_negative_rate y seleccione false_positive_rate para ver la métrica que nos interesa.

Tenga en cuenta que para comparar de manera justa las dos versiones de nuestro modelo, es importante utilizar umbrales que establezcan que la tasa general de falsos positivos sea aproximadamente igual. Esto asegura que estemos viendo un cambio real en lugar de solo un cambio en el modelo equivalente a simplemente mover el límite del umbral. En nuestro caso, comparar el modelo sin restricciones en 0.5 y el modelo restringido en 0.22 proporciona una comparación justa para los modelos.

eval_results_dict = {
    'constrained': eval_result_constrained,
    'unconstrained': eval_results_unconstrained,
}
tfma.addons.fairness.view.widget_view.render_fairness_indicator(multi_eval_results=eval_results_dict)
FairnessIndicatorViewer(evalName='constrained', evalNameCompare='unconstrained', slicingMetrics=[{'sliceValue'…

Con la capacidad de TFCO para expresar un requisito más complejo como una restricción de tasa, ayudamos a este modelo a lograr un resultado más deseable con poco impacto en el desempeño general. Por supuesto, todavía hay margen de mejora, pero al menos TFCO pudo encontrar un modelo que se acerca a satisfacer la restricción y reduce la disparidad entre los grupos tanto como sea posible.