使用 CelebA 数据集进行 TensorFlow 受限优化示例

在 TensorFlow.org 上查看 在 Google Colab 中运行 在 GitHub 上查看 下载笔记本

本笔记本演示了使用 TFCO 库创建和优化受限问题的简单方法。当我们发现模型在不同数据切片上的表现不一致时(可以通过 公平性指标 (Fairness Indicators) 来识别),这种方法有助于改进模型。谷歌 AI 原则中的第二条指出,我们的技术应避免制造或强化不公平的偏见,我们相信该技术可以在某些情况下帮助提高模型公平性。具体而言,本笔记本将

  • 训练一个简单的无约束神经网络模型,使用 tf.keras 和大规模 CelebFaces 属性 (CelebA) 数据集来检测图像中的人物是否在微笑。
  • 使用公平性指标,针对不同年龄组评估模型在常用公平性指标上的表现。
  • 设置一个简单的受限优化问题,以在不同年龄组之间实现更公平的表现。
  • 重新训练现有的受限模型并再次评估性能,确保我们选择的公平性指标得到改善。

最后更新:2020 年 2 月 11 日

安装

本笔记本是在 Colaboratory 中创建的,连接到 Python 3 Google Compute Engine 后端。如果您希望在不同的环境中托管此笔记本,只要您在下面的单元格中包含了所有必需的软件包,就不会遇到任何重大问题。

请注意,您第一次运行 pip 安装时,可能会因为预装了过期的软件包而要求您重启运行时。重启后,系统将使用正确的软件包。

Pip 安装

请注意,根据您运行下方单元格的时间,您可能会收到关于 Colab 中的 TensorFlow 默认版本即将切换到 TensorFlow 2.X 的警告。您可以安全地忽略该警告,因为本笔记本的设计兼容 TensorFlow 1.X 和 2.X。

导入模块

此外,我们还添加了一些特定于公平性指标的导入内容,我们将使用它们来评估和可视化模型的性能。

尽管 TFCO 兼容即时执行 (eager execution) 和图执行 (graph execution),但本笔记本假设即时执行是默认启用的(正如在 TensorFlow 2.x 中那样)。为确保不出错,将在下方的单元格中启用即时执行。

启用即时执行并打印版本

CelebA 数据集

CelebA 是一个大规模的人脸属性数据集,包含超过 200,000 张名人图像,每张图像都有 40 个属性标注(如发型、时尚配饰、面部特征等)和 5 个地标位置(眼睛、嘴巴和鼻子的位置)。有关更多详细信息,请参阅 论文。在所有者的许可下,我们将此数据集存储在 Google Cloud Storage 上,并主要通过 TensorFlow 数据集 (tfds) 进行访问。

在本笔记本中

  • 我们的模型将尝试分类图像主体是否在微笑,由“微笑”属性*表示。
  • 图像将从 218x178 调整为 28x28,以减少训练时的执行时间和内存占用。
  • 我们将使用二元“年轻”属性,在不同年龄组中评估模型的表现。在本笔记本中,我们将其称为“年龄组”。

* 虽然关于此数据集的标注方法可用的信息很少,但我们假设“微笑”属性是由主体脸上愉悦、友善或觉得有趣的神情来确定的。出于本案例研究的目的,我们将这些标签视为事实真值。

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)

测试数据集辅助函数

注意事项

在继续之前,在使用 CelebA 时有几个注意事项需要牢记

  • 尽管原则上本笔记本可以使用任何人脸图像数据集,但选择 CelebA 是因为它包含公众人物的公有领域图像。
  • CelebA 中的所有属性标注都操作化为二元类别。例如,“年轻”属性(由数据集标注员确定)在图像中被标记为存在或不存在。
  • CelebA 的分类并不能反映人类属性的真实多样性。
  • 出于本笔记本的目的,包含“年轻”属性的特征被称为“年龄组”,图像中存在“年轻”属性被标记为“年轻”年龄组成员,而缺失“年轻”属性则被标记为“非年轻”年龄组成员。这些是所做的假设,因为原始论文中未提及此信息。
  • 因此,本笔记本中训练的模型表现与 CelebA 作者对属性的操作化和标注方式有关。
  • 此模型不应用于商业目的,因为这将违反 CelebA 的非商业研究协议

设置输入函数

后续单元格将有助于简化输入管道以及可视化性能。

首先,我们定义一些与数据相关的变量,并定义必要的预处理函数。

定义变量

定义预处理函数

然后,我们构建在其余 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()

构建一个简单的 DNN 模型

由于本笔记本重点关注 TFCO,我们将组装一个简单的、无约束的 tf.keras.Sequential 模型。

我们或许可以通过增加一些复杂性(例如,更多密集连接层、探索不同的激活函数、增加图像尺寸)来极大地提高模型性能,但这可能会分散我们演示在 Keras 中应用 TFCO 库有多容易这一目标的注意力。因此,模型将保持简单——但鼓励您自行探索该领域。

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

我们还定义了一个设置种子的函数,以确保结果可重现。请注意,此 colab 旨在作为教育工具,不具备经过微调的生产管道那样的稳定性。如果不设置种子运行,可能会导致结果各异。

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

公平性指标辅助函数

在训练模型之前,我们定义了许多辅助函数,使我们能够通过公平性指标来评估模型的性能。

首先,我们创建一个辅助函数,在模型训练完成后对其进行保存。

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

接下来,我们定义用于预处理数据的函数,以便将其正确传递给 TFMA。

数据预处理函数用于

最后,我们定义一个在 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)

训练并评估无约束模型

模型定义完毕且输入管道就绪后,我们现在可以训练模型了。为了减少执行时间和内存开销,我们将通过将数据切分为小批量并进行少量重复迭代来训练模型。

请注意,在 TensorFlow < 2.0.0 中运行此笔记本可能会导致 np.where 的弃用警告。您可以安全地忽略此警告,因为 TensorFlow 在 2.X 中通过使用 tf.where 替换 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)

在测试数据上评估模型应该会得到刚刚超过 85% 的最终准确率分数。对于一个没有微调的简单模型来说,表现还不错。

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)

然而,在不同年龄组中评估的性能可能会暴露出一些缺陷。

为了进一步探索这一点,我们使用公平性指标(通过 TFMA)来评估模型。特别地,我们有兴趣查看当根据假阳性率进行评估时,“年轻”和“非年轻”类别之间的性能是否存在显著差距。

当模型错误地预测正类时,会发生假阳性错误。在这种情况下,当真实情况是“未微笑”的名人图像而模型预测为“微笑”时,就会发生假阳性结果。由此推论,上述可视化中使用的假阳性率是测试准确度的一种度量。虽然在当前语境下这是一种相对普通的错误,但假阳性错误有时会导致更有问题的行为。例如,垃圾邮件分类器中的假阳性错误可能会导致用户错过一封重要的电子邮件。

model_location = save_model(model_unconstrained, 'model_export_unconstrained')
eval_results_unconstrained = get_eval_results(model_location, 'eval_results_unconstrained')

如上所述,我们专注于假阳性率。当前版本的公平性指标 (0.1.2) 默认选择假阴性率。运行以下代码行后,取消选择 false_negative_rate 并选择 false_positive_rate,以查看我们感兴趣的指标。

tfma.addons.fairness.view.widget_view.render_fairness_indicator(eval_results_unconstrained)

正如上面的结果所示,我们确实看到了“年轻”和“非年轻”类别之间存在不成比例的差距

这就是 TFCO 可以发挥作用的地方,它将假阳性率限制在更可接受的标准范围内。

受限模型设置

正如 TFCO 库 中记录的那样,有几个辅助工具可以使约束问题变得更容易

  1. tfco.rate_context() – 这将用于为每个年龄组类别构建约束。
  2. tfco.RateMinimizationProblem() – 此处要最小化的速率表达式将是受年龄组影响的假阳性率。换句话说,性能现在将根据年龄组的假阳性率与整个数据集的假阳性率之间的差异来评估。对于此演示,将设置小于或等于 5% 的假阳性率作为约束。
  3. tfco.ProxyLagrangianOptimizerV2() – 这是实际解决速率约束问题的辅助工具。

下面的单元格将调用这些辅助工具,以设置带有公平性约束的模型训练。

# 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.legacy.Adam(learning_rate=0.001),
      constraint_optimizer=tf.keras.optimizers.legacy.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())

模型现已设置完毕,准备好在不同年龄组之间使用假阳性率约束进行训练。

现在,由于受限模型的最后一次迭代不一定是根据定义的约束表现最好的模型,TFCO 库配备了 tfco.find_best_candidate_index(),可以帮助在每个 epoch 之后找到的迭代中选择最佳候选者。可以将 tfco.find_best_candidate_index() 视为一种附加的启发式方法,它分别根据准确度和公平性约束(在本例中为跨年龄组的假阳性率)对每个结果进行排名。通过这种方式,它可以搜索总体准确度与公平性约束之间更好的权衡。

接下来的单元格将开始带有约束的训练,同时找到每次迭代中表现最好的模型。

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

应用约束后,我们再次使用公平性指标评估结果。

model_location = save_model(model_constrained, 'model_export_constrained')
eval_result_constrained = get_eval_results(model_location, 'eval_results_constrained')

与上次使用公平性指标时一样,取消选择 false_negative_rate 并选择 false_positive_rate,以查看我们感兴趣的指标。

请注意,为了公平地比较我们模型的两个版本,使用将整体假阳性率设置得大致相等的阈值很重要。这确保了我们是在观察实际的变化,而不是仅仅观察模型中等同于移动阈值边界的偏移。在我们的案例中,将 0.5 处的无约束模型与 0.22 处的受限模型进行比较,可以为这些模型提供一个公平的比较基准。

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)

借助 TFCO 将更复杂的需求表达为速率约束的能力,我们帮助该模型在对整体性能影响很小的情况下实现了更理想的结果。当然,仍有改进的空间,但至少 TFCO 能够找到一个接近满足约束条件并尽可能减少组间差异的模型。