Wiki 留言毒性預測

在 TensorFlow.org 上檢視 在 Google Colab 中執行 在 GitHub 上檢視 下載筆記本

在此範例中,我們考量預測張貼在 Wiki 討論頁面上的留言是否包含毒性內容 (亦即是否包含「粗魯、不尊重或不合理」的內容) 的任務。我們使用 Conversation AI 專案發布的公開資料集,其中包含超過 10 萬則來自英文維基百科的留言,這些留言已由群眾工作者加上註解 (請參閱論文,瞭解標記方法)。

此資料集面臨的挑戰之一是,只有極少部分的留言涵蓋性或宗教等敏感主題。因此,在此資料集上訓練神經網路模型會導致在較小的敏感主題上產生不同的效能。這可能表示關於這些主題的無害陳述可能會以較高的頻率被錯誤地標記為「有毒」,導致言論遭到不公平的審查。

透過在訓練期間施加限制,我們可以訓練出在不同主題群組中表現更公平的更公平模型。

我們將使用 TFCO 程式庫在訓練期間針對我們的公平性目標進行最佳化。

安裝

首先,讓我們安裝並匯入相關的程式庫。請注意,在執行第一個儲存格後,您可能必須重新啟動 Colab 一次,因為執行階段中的套件已過時。完成後,匯入時應不會再有問題。

pip 安裝

請注意,根據您執行下方儲存格的時間,您可能會收到關於 Colab 中 TensorFlow 預設版本即將切換至 TensorFlow 2.X 的警告。您可以安全地忽略該警告,因為此筆記本的設計與 TensorFlow 1.X 和 2.X 相容。

匯入模組

雖然 TFCO 與即時和圖形執行相容,但此筆記本假設預設啟用即時執行。為了確保不會發生任何問題,將在下方的儲存格中啟用即時執行。

啟用即時執行並列印版本

超參數

首先,我們設定資料前處理和模型訓練所需的一些超參數。

hparams = {
    "batch_size": 128,
    "cnn_filter_sizes": [128, 128, 128],
    "cnn_kernel_sizes": [5, 5, 5],
    "cnn_pooling_sizes": [5, 5, 40],
    "constraint_learning_rate": 0.01,
    "embedding_dim": 100,
    "embedding_trainable": False,
    "learning_rate": 0.005,
    "max_num_words": 10000,
    "max_sequence_length": 250
}

載入和預先處理資料集

接下來,我們下載資料集並預先處理它。訓練集、測試集和驗證集以個別 CSV 檔案的形式提供。

toxicity_data_url = ("https://github.com/conversationai/unintended-ml-bias-analysis/"
                     "raw/e02b9f12b63a39235e57ba6d3d62d8139ca5572c/data/")

data_train = pd.read_csv(toxicity_data_url + "wiki_train.csv")
data_test = pd.read_csv(toxicity_data_url + "wiki_test.csv")
data_vali = pd.read_csv(toxicity_data_url + "wiki_dev.csv")

data_train.head()

comment 欄包含討論留言,而 is_toxic 欄則指出留言是否已註解為有毒。

在以下步驟中,我們將

  1. 分離標籤
  2. 將文字留言符號化
  3. 識別包含敏感主題詞彙的留言

首先,我們從訓練集、測試集和驗證集中分離標籤。標籤都是二元 (0 或 1)。

labels_train = data_train["is_toxic"].values.reshape(-1, 1) * 1.0
labels_test = data_test["is_toxic"].values.reshape(-1, 1) * 1.0
labels_vali = data_vali["is_toxic"].values.reshape(-1, 1) * 1.0

接下來,我們使用 Keras 提供的 Tokenizer 將文字留言符號化。我們僅使用訓練集留言來建立詞彙符號,並使用它們將所有留言轉換為相同長度的 (已填補) 符號序列。

tokenizer = text.Tokenizer(num_words=hparams["max_num_words"])
tokenizer.fit_on_texts(data_train["comment"])

def prep_text(texts, tokenizer, max_sequence_length):
    # Turns text into into padded sequences.
    text_sequences = tokenizer.texts_to_sequences(texts)
    return sequence.pad_sequences(text_sequences, maxlen=max_sequence_length)

text_train = prep_text(data_train["comment"], tokenizer, hparams["max_sequence_length"])
text_test = prep_text(data_test["comment"], tokenizer, hparams["max_sequence_length"])
text_vali = prep_text(data_vali["comment"], tokenizer, hparams["max_sequence_length"])

最後,我們識別與某些敏感主題群組相關的留言。我們考量 身分詞彙的子集,這些詞彙與資料集一起提供,並將它們分成四個廣泛的主題群組:性別認同宗教種族

terms = {
    'sexuality': ['gay', 'lesbian', 'bisexual', 'homosexual', 'straight', 'heterosexual'], 
    'gender identity': ['trans', 'transgender', 'cis', 'nonbinary'],
    'religion': ['christian', 'muslim', 'jewish', 'buddhist', 'catholic', 'protestant', 'sikh', 'taoist'],
    'race': ['african', 'african american', 'black', 'white', 'european', 'hispanic', 'latino', 'latina', 
             'latinx', 'mexican', 'canadian', 'american', 'asian', 'indian', 'middle eastern', 'chinese', 
             'japanese']}

group_names = list(terms.keys())
num_groups = len(group_names)

然後,我們為訓練集、測試集和驗證集建立個別的群組成員資格矩陣,其中列對應於留言,欄對應於四個敏感群組,而每個條目都是布林值,指出留言是否包含來自主題群組的詞彙。

def get_groups(text):
    # Returns a boolean NumPy array of shape (n, k), where n is the number of comments, 
    # and k is the number of groups. Each entry (i, j) indicates if the i-th comment 
    # contains a term from the j-th group.
    groups = np.zeros((text.shape[0], num_groups))
    for ii in range(num_groups):
        groups[:, ii] = text.str.contains('|'.join(terms[group_names[ii]]), case=False)
    return groups

groups_train = get_groups(data_train["comment"])
groups_test = get_groups(data_test["comment"])
groups_vali = get_groups(data_vali["comment"])

如下所示,所有四個主題群組僅佔整體資料集的一小部分,並且有不同比例的有毒留言。

print("Overall label proportion = %.1f%%" % (labels_train.mean() * 100))

group_stats = []
for ii in range(num_groups):
    group_proportion = groups_train[:, ii].mean()
    group_pos_proportion = labels_train[groups_train[:, ii] == 1].mean()
    group_stats.append([group_names[ii],
                        "%.2f%%" % (group_proportion * 100), 
                        "%.1f%%" % (group_pos_proportion * 100)])
group_stats = pd.DataFrame(group_stats, 
                           columns=["Topic group", "Group proportion", "Label proportion"])
group_stats

我們看到只有 1.3% 的資料集包含與性相關的留言。在這些留言中,有 37% 的留言已註解為有毒。請注意,這遠高於註解為有毒的留言的總體比例。這可能是因為少數使用這些身分詞彙的留言是在貶義語境中使用的。如上所述,這可能會導致我們的模型在包含這些詞彙時,不成比例地將留言錯誤分類為有毒。由於這是我們擔心的問題,因此我們務必在評估模型效能時查看假陽性率

建構 CNN 毒性預測模型

準備好資料集後,我們現在建構 Keras 模型以預測毒性。我們使用的模型是卷積神經網路 (CNN),其架構與 Conversation AI 專案用於其偏差分析的架構相同。我們調整他們提供的 程式碼來建構模型層。

模型使用嵌入層將文字符號轉換為固定長度的向量。此層將輸入文字序列轉換為向量序列,並將它們傳遞到多個卷積和池化運算層,然後是最終的全連接層。

我們使用預先訓練的 GloVe 字詞向量嵌入,我們將在下方下載。這可能需要幾分鐘才能完成。

zip_file_url = "http://nlp.stanford.edu/data/glove.6B.zip"
zip_file = urllib.request.urlopen(zip_file_url)
archive = zipfile.ZipFile(io.BytesIO(zip_file.read()))

我們使用下載的 GloVe 嵌入來建立嵌入矩陣,其中列包含 Tokenizer 詞彙表中符號的字詞嵌入。

embeddings_index = {}
glove_file = "glove.6B.100d.txt"

with archive.open(glove_file) as f:
    for line in f:
        values = line.split()
        word = values[0].decode("utf-8") 
        coefs = np.asarray(values[1:], dtype="float32")
        embeddings_index[word] = coefs

embedding_matrix = np.zeros((len(tokenizer.word_index) + 1, hparams["embedding_dim"]))
num_words_in_embedding = 0
for word, i in tokenizer.word_index.items():
    embedding_vector = embeddings_index.get(word)
    if embedding_vector is not None:
        num_words_in_embedding += 1
        embedding_matrix[i] = embedding_vector

我們現在準備好指定 Keras 層。我們編寫一個函數來建立新模型,每當我們想要訓練新模型時,都會調用該函數。

def create_model():
    model = keras.Sequential()

    # Embedding layer.
    embedding_layer = layers.Embedding(
        embedding_matrix.shape[0],
        embedding_matrix.shape[1],
        weights=[embedding_matrix],
        input_length=hparams["max_sequence_length"],
        trainable=hparams['embedding_trainable'])
    model.add(embedding_layer)

    # Convolution layers.
    for filter_size, kernel_size, pool_size in zip(
        hparams['cnn_filter_sizes'], hparams['cnn_kernel_sizes'],
        hparams['cnn_pooling_sizes']):

        conv_layer = layers.Conv1D(
            filter_size, kernel_size, activation='relu', padding='same')
        model.add(conv_layer)

        pooled_layer = layers.MaxPooling1D(pool_size, padding='same')
        model.add(pooled_layer)

    # Add a flatten layer, a fully-connected layer and an output layer.
    model.add(layers.Flatten())
    model.add(layers.Dense(128, activation='relu'))
    model.add(layers.Dense(1))

    return model

我們也定義了一種設定隨機種子的方法。這樣做是為了確保結果可重現。

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

公平性指標

我們也編寫函數來繪製公平性指標。

def create_examples(labels, predictions, groups, group_names):
  # Returns tf.examples with given labels, predictions, and group information.  
  examples = []
  sigmoid = lambda x: 1/(1 + np.exp(-x)) 
  for ii in range(labels.shape[0]):
    example = tf.train.Example()
    example.features.feature['toxicity'].float_list.value.append(
        labels[ii][0])
    example.features.feature['prediction'].float_list.value.append(
        sigmoid(predictions[ii][0]))  # predictions need to be in [0, 1].
    for jj in range(groups.shape[1]):
      example.features.feature[group_names[jj]].bytes_list.value.append(
          b'Yes' if groups[ii, jj] else b'No')
    examples.append(example)
  return examples
def evaluate_results(labels, predictions, groups, group_names):
  # Evaluates fairness indicators for given labels, predictions and group
  # membership info.
  examples = create_examples(labels, predictions, groups, group_names)

  # Create feature map for labels, predictions and each group.
  feature_map = {
      'prediction': tf.io.FixedLenFeature([], tf.float32),
      'toxicity': tf.io.FixedLenFeature([], tf.float32),
  }
  for group in group_names:
    feature_map[group] = tf.io.FixedLenFeature([], tf.string)

  # Serialize the examples.
  serialized_examples = [e.SerializeToString() for e in examples]

  BASE_DIR = tempfile.gettempdir()
  OUTPUT_DIR = os.path.join(BASE_DIR, 'output')

  with beam.Pipeline() as pipeline:
    model_agnostic_config = agnostic_predict.ModelAgnosticConfig(
              label_keys=['toxicity'],
              prediction_keys=['prediction'],
              feature_spec=feature_map)

    slices = [tfma.slicer.SingleSliceSpec()]
    for group in group_names:
      slices.append(
          tfma.slicer.SingleSliceSpec(columns=[group]))

    extractors = [
            model_agnostic_extractor.ModelAgnosticExtractor(
                model_agnostic_config=model_agnostic_config),
            tfma.extractors.slice_key_extractor.SliceKeyExtractor(slices)
        ]

    metrics_callbacks = [
      tfma.post_export_metrics.fairness_indicators(
          thresholds=[0.5],
          target_prediction_keys=['prediction'],
          labels_key='toxicity'),
      tfma.post_export_metrics.example_count()]

    # Create a model agnostic aggregator.
    eval_shared_model = tfma.types.EvalSharedModel(
        add_metrics_callbacks=metrics_callbacks,
        construct_fn=model_agnostic_evaluate_graph.make_construct_fn(
            add_metrics_callbacks=metrics_callbacks,
            config=model_agnostic_config))

    # Run Model Agnostic Eval.
    _ = (
        pipeline
        | beam.Create(serialized_examples)
        | 'ExtractEvaluateAndWriteResults' >>
          tfma.ExtractEvaluateAndWriteResults(
              eval_shared_model=eval_shared_model,
              output_path=OUTPUT_DIR,
              extractors=extractors,
              compute_confidence_intervals=True
          )
    )

  fairness_ind_result = tfma.load_eval_result(output_path=OUTPUT_DIR)

  # Also evaluate accuracy of the model.
  accuracy = np.mean(labels == (predictions > 0.0))

  return fairness_ind_result, accuracy
def plot_fairness_indicators(eval_result, title):
  fairness_ind_result, accuracy = eval_result
  display(HTML("<center><h2>" + title + 
               " (Accuracy = %.2f%%)" % (accuracy * 100) + "</h2></center>"))
  widget_view.render_fairness_indicator(fairness_ind_result)
def plot_multi_fairness_indicators(multi_eval_results):

  multi_results = {}
  multi_accuracy = {}
  for title, (fairness_ind_result, accuracy) in multi_eval_results.items():
    multi_results[title] = fairness_ind_result
    multi_accuracy[title] = accuracy

  title_str = "<center><h2>"
  for title in multi_eval_results.keys():
      title_str+=title + " (Accuracy = %.2f%%)" % (multi_accuracy[title] * 100) + "; "
  title_str=title_str[:-2]
  title_str+="</h2></center>"
  # fairness_ind_result, accuracy = eval_result
  display(HTML(title_str))
  widget_view.render_fairness_indicator(multi_eval_results=multi_results)

訓練無約束模型

對於我們訓練的第一個模型,我們最佳化簡單的交叉熵損失,包含任何約束。

# Set random seed for reproducible results.
set_seeds()
# Optimizer and loss.
optimizer = tf.keras.optimizers.Adam(learning_rate=hparams["learning_rate"])
loss = lambda y_true, y_pred: tf.keras.losses.binary_crossentropy(
    y_true, y_pred, from_logits=True)

# Create, compile and fit model.
model_unconstrained = create_model()
model_unconstrained.compile(optimizer=optimizer, loss=loss)

model_unconstrained.fit(
    x=text_train, y=labels_train, batch_size=hparams["batch_size"], epochs=2)

訓練完無約束模型後,我們繪製測試集上模型的各種評估指標。

scores_unconstrained_test = model_unconstrained.predict(text_test)
eval_result_unconstrained = evaluate_results(
    labels_test, scores_unconstrained_test, groups_test, group_names)

如上所述,我們專注於假陽性率。在其目前版本 (0.1.2) 中,公平性指標預設選取假陰性率。在執行下方行後,繼續取消選取 false_negative_rate 並選取 false_positive_rate 以查看我們感興趣的指標。

plot_fairness_indicators(eval_result_unconstrained, "Unconstrained")

雖然整體假陽性率低於 2%,但與性相關的留言的假陽性率顯著較高。這是因為性群組的規模非常小,並且註解為有毒的留言比例不成比例地較高。因此,在沒有約束的情況下訓練模型會導致模型認為與性相關的詞彙是毒性的強烈指標。

在假陽性率上施加約束的情況下進行訓練

為了避免不同群組之間假陽性率的巨大差異,我們接下來訓練一個模型,方法是將每個群組的假陽性率限制在所需的限制範圍內。在本例中,我們將最佳化模型的錯誤率,但每個群組的假陽性率必須小於或等於 2%

然而,對於此資料集,使用每個群組的約束在小批次上進行訓練可能具有挑戰性,因為我們希望約束的群組規模都很小,而且個別小批次很可能包含來自每個群組的範例非常少。因此,我們在訓練期間計算的梯度將會很嘈雜,並導致模型收斂速度非常慢。

為了減輕此問題,我們建議使用兩個小批次串流,第一個串流像以前一樣從整個訓練集形成,第二個串流僅從敏感群組範例形成。我們將使用來自第一個串流的小批次計算目標,並使用來自第二個串流的小批次計算每個群組的約束。由於來自第二個串流的批次可能包含來自每個群組的更多範例,因此我們預期我們的更新雜訊會較少。

我們建立個別的特徵、標籤和群組張量,以保存來自兩個串流的小批次。

# Set random seed.
set_seeds()

# Features tensors.
batch_shape = (hparams["batch_size"], hparams['max_sequence_length'])
features_tensor = tf.Variable(np.zeros(batch_shape, dtype='int32'), name='x')
features_tensor_sen = tf.Variable(np.zeros(batch_shape, dtype='int32'), name='x_sen')

# Labels tensors.
batch_shape = (hparams["batch_size"], 1)
labels_tensor = tf.Variable(np.zeros(batch_shape, dtype='float32'), name='labels')
labels_tensor_sen = tf.Variable(np.zeros(batch_shape, dtype='float32'), name='labels_sen')

# Groups tensors.
batch_shape = (hparams["batch_size"], num_groups)
groups_tensor_sen = tf.Variable(np.zeros(batch_shape, dtype='float32'), name='groups_sen')

我們實例化一個新模型,並計算來自兩個串流的小批次的預測。

# Create model, and separate prediction functions for the two streams. 
# For the predictions, we use a nullary function returning a Tensor to support eager mode.
model_constrained = create_model()

def predictions():
  return model_constrained(features_tensor)

def predictions_sen():
  return model_constrained(features_tensor_sen)

然後,我們設定一個受限最佳化問題,其中錯誤率作為目標,並對每個群組的假陽性率施加約束。

epsilon = 0.02  # Desired false-positive rate threshold.

# Set up separate contexts for the two minibatch streams.
context = tfco.rate_context(predictions, lambda:labels_tensor)
context_sen = tfco.rate_context(predictions_sen, lambda:labels_tensor_sen)

# Compute the objective using the first stream.
objective = tfco.error_rate(context)

# Compute the constraint using the second stream.
# Subset the examples belonging to the "sexuality" group from the second stream 
# and add a constraint on the group's false positive rate.
context_sen_subset = context_sen.subset(lambda: groups_tensor_sen[:, 0] > 0)
constraint = [tfco.false_positive_rate(context_sen_subset) <= epsilon]

# Create a rate minimization problem.
problem = tfco.RateMinimizationProblem(objective, constraint)

# Set up a constrained optimizer.
optimizer = tfco.ProxyLagrangianOptimizerV2(
    optimizer=tf.keras.optimizers.Adam(learning_rate=hparams["learning_rate"]),
    num_constraints=problem.num_constraints)

# List of variables to optimize include the model weights, 
# and the trainable variables from the rate minimization problem and 
# the constrained optimizer.
var_list = (model_constrained.trainable_weights + list(problem.trainable_variables) +
            optimizer.trainable_variables())

我們準備好訓練模型了。我們為兩個小批次串流維護一個單獨的計數器。每次執行梯度更新時,我們都必須將來自第一個串流的小批次內容複製到張量 features_tensorlabels_tensor,並將來自第二個串流的小批次內容複製到張量 features_tensor_senlabels_tensor_sengroups_tensor_sen

# Indices of sensitive group members.
protected_group_indices = np.nonzero(groups_train.sum(axis=1))[0]

num_examples = text_train.shape[0]
num_examples_sen = protected_group_indices.shape[0]
batch_size = hparams["batch_size"]

# Number of steps needed for one epoch over the training sample.
num_steps = int(num_examples / batch_size)

start_time = time.time()

# Loop over minibatches.
for batch_index in range(num_steps):
    # Indices for current minibatch in the first stream.
    batch_indices = np.arange(
        batch_index * batch_size, (batch_index + 1) * batch_size)
    batch_indices = [ind % num_examples for ind in batch_indices]

    # Indices for current minibatch in the second stream.
    batch_indices_sen = np.arange(
        batch_index * batch_size, (batch_index + 1) * batch_size)
    batch_indices_sen = [protected_group_indices[ind % num_examples_sen]
                         for ind in batch_indices_sen]

    # Assign features, labels, groups from the minibatches to the respective tensors.
    features_tensor.assign(text_train[batch_indices, :])
    labels_tensor.assign(labels_train[batch_indices])

    features_tensor_sen.assign(text_train[batch_indices_sen, :])
    labels_tensor_sen.assign(labels_train[batch_indices_sen])
    groups_tensor_sen.assign(groups_train[batch_indices_sen, :])

    # Gradient update.
    optimizer.minimize(problem, var_list=var_list)

    # Record and print batch training stats every 10 steps.
    if (batch_index + 1) % 10 == 0 or batch_index in (0, num_steps - 1):
      hinge_loss = problem.objective()
      max_violation = max(problem.constraints())

      elapsed_time = time.time() - start_time
      sys.stdout.write(
          "\rStep %d / %d: Elapsed time = %ds, Loss = %.3f, Violation = %.3f" % 
          (batch_index + 1, num_steps, elapsed_time, hinge_loss, max_violation))

訓練完受約束模型後,我們繪製測試集上模型的各種評估指標。

scores_constrained_test = model_constrained.predict(text_test)
eval_result_constrained = evaluate_results(
    labels_test, scores_constrained_test, groups_test, group_names)

與上次一樣,請記住選取 false_positive_rate。

plot_fairness_indicators(eval_result_constrained, "Constrained")
multi_results = {
    'constrained':eval_result_constrained,
    'unconstrained':eval_result_unconstrained,
}
plot_multi_fairness_indicators(multi_eval_results=multi_results)

從公平性指標中我們可以看出,與無約束模型相比,受約束模型在與性相關的留言中產生明顯較低的假陽性率,並且整體準確度僅略微下降。