Exemplo de otimização restrita do TensorFlow usando o conjunto de dados CelebA

Veja no TensorFlow.org Executar no Google Colab Ver no GitHub Baixar caderno

Este bloco de notas demonstra uma maneira fácil de criar e otimizar problemas restritos usando a biblioteca TFCO. Este método pode ser útil para melhorar os modelos quando descobrimos que eles não estão realizando igualmente bem em diferentes fatias de nossos dados, que podemos identificar usando Fairness Indicadores . O segundo princípio de IA do Google afirma que nossa tecnologia deve evitar criar ou reforçar preconceitos injustos e acreditamos que essa técnica pode ajudar a melhorar a justiça do modelo em algumas situações. Em particular, este notebook irá:

  • Treinar um simples modelo de rede neural sem restrições para detectar o sorriso de uma pessoa em imagens usando tf.keras e os CelebFaces grande escala Atributos ( CelebA ) do conjunto de dados.
  • Avalie o desempenho do modelo em relação a uma métrica de justiça comumente usada em todas as faixas etárias, usando indicadores de justiça.
  • Configure um problema simples de otimização restrita para alcançar um desempenho mais justo em todas as faixas etárias.
  • Treinar o modelo agora constrangidos e avaliar o desempenho novamente, garantindo que a nossa métrica justiça escolhido melhorou.

Última atualização: 3/11 de fevereiro de 2020

Instalação

Este notebook foi criado em Colaboratory , ligado ao Python 3 Google Compute Engine backend. Se você deseja hospedar este notebook em um ambiente diferente, não deverá ter nenhum problema grave, desde que inclua todos os pacotes necessários nas células abaixo.

Observe que na primeira vez que você executa as instalações do pip, pode ser solicitado que você reinicie o tempo de execução devido a pacotes desatualizados pré-instalados. Depois de fazer isso, os pacotes corretos serão usados.

Instalações pip

Dependendo de quando você executa a célula abaixo, você pode receber um aviso sobre a versão padrão do TensorFlow no Colab mudando para o TensorFlow 2.X em breve. Você pode ignorar esse aviso com segurança, pois este notebook foi projetado para ser compatível com o TensorFlow 1.X e 2.X.

Módulos de importação

Além disso, adicionamos algumas importações que são específicas para Indicadores de Justiça que usaremos para avaliar e visualizar o desempenho do modelo.

Embora o TFCO seja compatível com a execução rápida e gráfica, este notebook assume que a execução antecipada está habilitada por padrão, pois está no TensorFlow 2.x. Para garantir que nada seja interrompido, a execução rápida será habilitada na célula abaixo.

Habilite a execução rápida e versões de impressão

Eager execution enabled by default.
TensorFlow 2.8.0-rc0
TFMA 0.36.0
TFDS 4.4.0
FI 0.36.0

Conjunto de dados CelebA

CelebA é atribui um rosto grande escala conjunto de dados com mais de 200.000 imagens de celebridades, cada um com 40 anotações de atributos (tais como tipo de cabelo, acessórios de moda, características faciais, etc.) e locais 5 marco (olhos, boca e nariz posições). Para mais informações dê uma olhada no papel . Com a permissão dos proprietários, temos armazenados neste conjunto de dados Google Cloud Storage e principalmente acessá-lo via TensorFlow conjuntos de dados ( tfds ) .

Neste caderno:

  • Nosso modelo tentará classificar se o assunto da imagem está sorrindo, como representado pelo "sorriso" atributo *.
  • As imagens serão redimensionadas de 218x178 a 28x28 para reduzir o tempo de execução e a memória durante o treinamento.
  • O desempenho do nosso modelo será avaliado em todas as faixas etárias, usando o atributo binário "Young". Chamaremos isso de "faixa etária" neste caderno.

* Embora haja pouca informação disponível sobre a metodologia de rotulagem para este conjunto de dados, vamos supor que o atributo "Sorriso" foi determinado por uma expressão satisfeita, tipo, ou divertido no rosto do sujeito. Para o propósito deste estudo de caso, consideraremos esses rótulos como verdades fundamentais.

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

Testar funções auxiliares do conjunto de dados

Ressalvas

Antes de prosseguir, há várias considerações a serem lembradas ao usar o CelebA:

  • Embora, em princípio, este notebook pudesse usar qualquer conjunto de dados de imagens de rosto, o CelebA foi escolhido porque contém imagens de domínio público de figuras públicas.
  • Todas as anotações de atributo no CelebA são operacionalizadas como categorias binárias. Por exemplo, o atributo "Jovem" (conforme determinado pelos rotuladores do conjunto de dados) é indicado como presente ou ausente na imagem.
  • As categorizações do CelebA não refletem a diversidade humana real de atributos.
  • Para os fins deste bloco de notas, o recurso que contém o atributo "Jovem" é referido como "faixa etária", onde a presença do atributo "Jovem" em uma imagem é rotulada como um membro do grupo de idade "Jovem" e o a ausência do atributo "Jovem" é rotulada como membro do grupo de idade "Não é jovem". Estes são suposições feitas como esta informação não é mencionado no artigo original .
  • Como tal, o desempenho nos modelos treinados neste notebook está vinculado à forma como os atributos foram operacionalizados e anotados pelos autores do CelebA.
  • Este modelo não deve ser utilizado para fins comerciais como isso violaria acordo de pesquisa não-comercial de CelebA .

Configurando funções de entrada

As células subsequentes ajudarão a otimizar o pipeline de entrada, bem como visualizar o desempenho.

Primeiro, definimos algumas variáveis ​​relacionadas aos dados e definimos uma função de pré-processamento de requisito.

Definir Variáveis

Definir funções de pré-processamento

Em seguida, construímos as funções de dados de que precisamos no restante do 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()

Construir um modelo DNN simples

Porque este notebook se concentra em TFCO, vamos montar um simples, sem restrições tf.keras.Sequential modelo.

Podemos melhorar muito o desempenho do modelo adicionando alguma complexidade (por exemplo, camadas mais densamente conectadas, explorando diferentes funções de ativação, aumentando o tamanho da imagem), mas isso pode nos desviar do objetivo de demonstrar como é fácil aplicar a biblioteca TFCO ao trabalhar com Keras. Por esse motivo, o modelo será mantido simples - mas sinta-se encorajado a explorar este espaço.

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

Também definimos uma função para definir sementes para garantir resultados reproduzíveis. Observe que este colab é uma ferramenta educacional e não tem a estabilidade de um pipeline de produção bem ajustado. Correr sem colocar uma semente pode levar a resultados variados.

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

Funções auxiliares dos indicadores de justiça

Antes de treinar nosso modelo, definimos uma série de funções auxiliares que nos permitirão avaliar o desempenho do modelo por meio de Indicadores de Equidade.

Primeiro, criamos uma função auxiliar para salvar nosso modelo depois de treiná-lo.

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 seguir, definimos as funções usadas para pré-processar os dados a fim de passá-los corretamente para o TFMA.

Funções de pré-processamento de dados para

Por fim, definimos uma função que avalia os resultados em 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)

Treinar e avaliar o modelo irrestrito

Com o modelo agora definido e o pipeline de entrada no lugar, estamos prontos para treinar nosso modelo. Para reduzir a quantidade de tempo de execução e memória, treinaremos o modelo dividindo os dados em pequenos lotes com apenas algumas iterações repetidas.

Note-se que a execução deste notebook em TensorFlow <2.0.0 pode resultar em uma advertência depreciação para np.where . Com segurança ignorar este aviso como TensorFlow aborda esta em 2.X usando tf.where no 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 [==============================] - 12s 6ms/step - loss: 0.5038 - accuracy: 0.7733
Epoch 2/5
1000/1000 [==============================] - 7s 7ms/step - loss: 0.3800 - accuracy: 0.8301
Epoch 3/5
1000/1000 [==============================] - 6s 6ms/step - loss: 0.3598 - accuracy: 0.8427
Epoch 4/5
1000/1000 [==============================] - 25s 25ms/step - loss: 0.3435 - accuracy: 0.8474
Epoch 5/5
1000/1000 [==============================] - 5s 5ms/step - loss: 0.3402 - accuracy: 0.8479
<keras.callbacks.History at 0x7f0f5c476350>

Avaliar o modelo nos dados de teste deve resultar em uma pontuação de precisão final de pouco mais de 85%. Nada mal para um modelo simples sem ajustes finos.

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 [==============================] - 50s 2ms/step - loss: 0.2125 - accuracy: 0.8636

No entanto, o desempenho avaliado em todas as faixas etárias pode revelar algumas deficiências.

Para explorar isso mais a fundo, avaliamos o modelo com Indicadores de Equidade (via TFMA). Em particular, estamos interessados ​​em ver se há uma lacuna significativa no desempenho entre as categorias "Jovem" e "Não jovem" quando avaliadas na taxa de falsos positivos.

Um erro falso positivo ocorre quando o modelo prediz incorretamente a classe positiva. Nesse contexto, um resultado falso positivo ocorre quando a verdade fundamental é a imagem de uma celebridade "Não Sorrindo" e a modelo prevê "Sorrindo". Por extensão, a taxa de falsos positivos, que é usada na visualização acima, é uma medida de precisão para um teste. Embora seja um erro relativamente mundano de se cometer neste contexto, os erros de falso positivo às vezes podem causar comportamentos mais problemáticos. Por exemplo, um erro de falso positivo em um classificador de spam pode fazer com que o usuário perca um e-mail importante.

model_location = save_model(model_unconstrained, 'model_export_unconstrained')
eval_results_unconstrained = get_eval_results(model_location, 'eval_results_unconstrained')
2022-01-07 18:46:05.881112: W tensorflow/python/util/util.cc:368] Sets are not currently considered sequences, but this may change in the future, so consider avoiding using them.
INFO:tensorflow:Assets written to: /tmp/saved_modelswhxcqdry/model_export_unconstrained/assets
INFO:tensorflow:Assets written to: /tmp/saved_modelswhxcqdry/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:root:Make sure that locally built Python SDK docker image has Python 3.7 interpreter.
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.7/site-packages/tensorflow_model_analysis/writers/metrics_plots_and_validations_writer.py:107: 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.7/site-packages/tensorflow_model_analysis/writers/metrics_plots_and_validations_writer.py:107: 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)`

Conforme mencionado acima, estamos nos concentrando na taxa de falsos positivos. A versão atual dos Indicadores de Equidade (0.1.2) seleciona a taxa de falsos negativos por padrão. Depois de executar a linha abaixo, desmarque false_negative_rate e selecione false_positive_rate para ver a métrica na qual estamos interessados.

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

Como os resultados mostram acima, nós vemos uma lacuna desproporcional entre "Young" e categorias "Não Jovens".

É aqui que o TFCO pode ajudar, restringindo a taxa de falsos positivos para um critério mais aceitável.

Configuração de modelo restrito

Conforme documentado na biblioteca de TFCO , há vários ajudantes que vão torná-lo mais fácil para restringir o problema:

  1. tfco.rate_context() - Este é o que vai ser usado na construção de uma restrição para cada categoria faixa etária.
  2. tfco.RateMinimizationProblem() - A expressão taxa a ser minimizada aqui será a taxa de falso positiva, sujeita a faixa etária. Em outras palavras, o desempenho agora será avaliado com base na diferença entre as taxas de falsos positivos da faixa etária e do conjunto de dados geral. Para esta demonstração, uma taxa de falsos positivos menor ou igual a 5% será definida como a restrição.
  3. tfco.ProxyLagrangianOptimizerV2() - Este é o auxiliar que vai realmente resolver o problema de restrição de taxa.

A célula abaixo chamará esses ajudantes para configurar o treinamento do modelo com a restrição de justiça.

# 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())

O modelo agora está configurado e pronto para ser treinado com a restrição de taxa de falsos positivos em toda a faixa etária.

Agora, porque a última iteração do modelo restrito pode não ser necessariamente o melhor modelo de realização em termos de restrição definida, a biblioteca TFCO vem equipado com tfco.find_best_candidate_index() que pode ajudar a escolher o melhor iterate fora aqueles encontrados após cada época. Pense tfco.find_best_candidate_index() como uma heurística acrescentou que classifica cada um dos resultados com base na precisão e restrição justiça (neste caso, a taxa de falso positivo em toda a faixa etária) separadamente em relação aos dados de treinamento. Dessa forma, ele pode buscar uma melhor compensação entre a precisão geral e a restrição de justiça.

As células a seguir iniciarão o treinamento com restrições enquanto também encontram o modelo de melhor desempenho por iteração.

# 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

Depois de aplicar a restrição, avaliamos os resultados mais uma vez usando Indicadores de Equidade.

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_modelsbztxt9fy/model_export_constrained/assets
INFO:tensorflow:Assets written to: /tmp/saved_modelsbztxt9fy/model_export_constrained/assets
WARNING:root:Make sure that locally built Python SDK docker image has Python 3.7 interpreter.

Como na vez anterior, usamos indicadores de imparcialidade, desmarque false_negative_rate e selecione false_positive_rate para ver a métrica na qual estamos interessados.

Observe que, para comparar de forma justa as duas versões do nosso modelo, é importante usar limites que definem a taxa geral de falsos positivos como aproximadamente igual. Isso garante que estamos olhando para a mudança real, ao invés de apenas uma mudança no modelo equivalente a simplesmente mover o limite do limite. Em nosso caso, comparar o modelo irrestrito em 0,5 e o modelo restrito em 0,22 fornece uma comparação justa para os 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'…

Com a capacidade do TFCO de expressar um requisito mais complexo como uma restrição de taxa, ajudamos este modelo a atingir um resultado mais desejável com pouco impacto no desempenho geral. É claro que ainda há espaço para melhorias, mas pelo menos o TFCO foi capaz de encontrar um modelo que chega perto de satisfazer a restrição e reduz a disparidade entre os grupos tanto quanto possível.