CTF中的最佳化隨機演算法(爬山&退火)

Lachrymosa發表於2024-08-29

對於連續的最佳化問題,典型的方法是梯度下降法;對於離散的最佳化問題,典型的方法是爬山法/退火法。

讓我們從一道簡單的題目入手吧。

任務:生成能夠欺騙文字分類模型的對抗性文字。
你需要對給定的positive、negative、neutral文字資料進行微小擾動,以確保模型對這些擾動後的文字進行錯誤預測。你必須保持原文字與擾動文字之間的語義相似度至少為75%。如果你成功生成了至少90%符合相似度要求且能欺騙模型的對抗性樣本,你將完成任務並獲得比賽的flag。

翻譯成人話大概是:模型是一個判別器,我們要做的是對輸入的文字微小擾動,使得模型對文字的判別錯誤。乍一看是對抗學習的內容,但實際上不需要。

問題本質上是構思一個演算法,去選擇特定的某些詞去替換文字,使得儘可能地偏離原本的類別(同義詞替換)。

怎樣替換呢?

Take 1 純隨機

十分樸素地,我們先嚐試隨機替換一句話的一個詞。

在這裡我們用到Wordnet的近義詞表,其中可以查到某個詞的多個近義詞。

純隨機演算法的步驟如下:

  1. 從句子 j 中隨機選擇 m 個詞。
  2. 對於選擇的每個詞(k 從 1 到 m),執行以下步驟:
    • a. 將詞 k 替換為它的一個近義詞。
    • b. 使用替換後的句子輸入模型,獲取模型的分類結果。
    • c. 判斷模型的分類結果:
      • 如果模型的分類結果與原來的分類結果相同,繼續處理下一個詞(continue)。
      • 如果模型的分類結果發生變化,則標記攻擊成功(break),將該句子新增到 list,從原句表中移除該句子。

基於這個流程,我們可以稍微包裝一下,變成:

  1. 建立一個空的列表 list 用於儲存成功的攻擊樣本。
  2. 進行 t 次迭代(i 從 1 到 t):
    • 2.1 對每個輸入的句子(j 從 1 到 n)執行以下操作:
      • 2.1.1 從句子 j 中隨機選擇 m 個詞。
      • 2.1.2 對於選擇的每個詞(k 從 1 到 m),執行以下步驟:
        • a. 找到詞 k 的不超過 p 個近義詞。
        • b. 對於每個近義詞(l 從 1 到 p),執行以下操作:
          • b.1 獲取將詞 k 替換為近義詞 l 後,模型的分類結果。
          • b.2 判斷模型的分類結果:
            • 如果模型的分類結果與原來的分類結果相同,繼續處理下一個近義詞(continue)。
            • 如果模型的分類結果發生變化,則標記攻擊成功(break),將該句子新增到 list,從原句表中移除該句子。
      • 2.1.3 對於沒有攻擊成功的句子 j,等待下一次迭代重新隨機選擇詞。
  3. 重複步驟 2,直到達到 t 次迭代或表為空為止。

其實到了這裡,已經拿到flag了。

random.py (90% 63s)
import pandas as pd
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from sklearn.metrics.pairwise import cosine_similarity
import nltk
from nltk.corpus import wordnet
import random
import time

nltk.download('wordnet')

NUM_ITERATIONS = 5
SIMILARITY_THRESHOLD = 0.75
MAX_WORDS_TO_SELECT = 10
MAX_SYNONYMS_TO_SELECT = 10

csv_path = 'original_text.csv'
data = pd.read_csv(csv_path)

model_path = 'Sentiment_classification_model'
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForSequenceClassification.from_pretrained(model_path)

def synonym_replacement(text, model, tokenizer):
    words = text.split()

    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    original_prediction = model(**original_encoding).logits.argmax(dim=1).item()

    for _ in range(MAX_WORDS_TO_SELECT):
        i = random.randint(0, len(words) - 1)
        synonyms = wordnet.synsets(words[i])
        if not synonyms:
            continue

        lemmas = set(lemma.name() for syn in synonyms for lemma in syn.lemmas() if lemma.name() != words[i])
        
        selected_synonyms = random.sample(list(lemmas), min(MAX_SYNONYMS_TO_SELECT, len(lemmas)))

        for synonym in selected_synonyms:
            new_words = words.copy()
            new_words[i] = synonym

            new_text = ' '.join(new_words)

            new_encoding = tokenizer(new_text, return_tensors='pt', padding=True, truncation=True, max_length=512)
            new_prediction = model(**new_encoding).logits.argmax(dim=1).item()

            if new_prediction != original_prediction:
                return new_text

    return text

def verify_similarity(original, modified, model, tokenizer):
    model.eval()
    original_encoding = tokenizer(original, return_tensors='pt', padding=True, truncation=True, max_length=512)
    modified_encoding = tokenizer(modified, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_outputs = model.distilbert(**original_encoding)
        original_hidden_state = original_outputs.last_hidden_state.mean(dim=1)

        modified_outputs = model.distilbert(**modified_encoding)
        modified_hidden_state = modified_outputs.last_hidden_state.mean(dim=1)

    similarity = cosine_similarity(original_hidden_state.cpu().numpy(),
                                   modified_hidden_state.cpu().numpy())[0][0]

    return similarity

def check_attack_success(text, attacked_text, model, tokenizer):
    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    attacked_encoding = tokenizer(attacked_text, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_prediction = model(**original_encoding).logits.argmax(dim=1).item()
        attacked_prediction = model(**attacked_encoding).logits.argmax(dim=1).item()

    return original_prediction != attacked_prediction

start_time = time.time()

attacked_texts = []

remaining_data = data.copy()

for iteration in range(NUM_ITERATIONS):
    print(f"Starting iteration {iteration + 1}...")
    successful_attacks = []
    to_remove = []

    for index, row in remaining_data.iterrows():
        original_text = row['text']
        attack_success = False

        attacked_text = synonym_replacement(original_text, model, tokenizer)
        similarity = verify_similarity(original_text, attacked_text, model, tokenizer)

        if similarity >= SIMILARITY_THRESHOLD:
            attack_success = check_attack_success(original_text, attacked_text, model, tokenizer)
            if attack_success:
                successful_attacks.append((row['id'], attacked_text))
                to_remove.append(index)

    attacked_texts.extend(successful_attacks)

    remaining_data = remaining_data.drop(to_remove)

    print(f"Iteration {iteration + 1}: {len(successful_attacks)} successful attacks.")

    if remaining_data.empty:
        print("All texts successfully attacked, ending early.")
        break

success_rate = len(attacked_texts) / len(data)
print(f'Final attack success rate: {success_rate * 100:.2f}%')

for index, row in remaining_data.iterrows():
    attacked_texts.append((row['id'], row['text']))

attacked_texts.sort(key=lambda x: x[0])

output_df = pd.DataFrame(attacked_texts, columns=['id', 'attacked_text'])
output_df.to_csv('attacked_text.csv', index=False)

end_time = time.time()

total_time = end_time - start_time
print(f"Total running time: {total_time:.2f} seconds")

Take 2 隨機+爬山

與其每次迭代都從原始狀態選擇詞的組合,不妨每次只選擇一個詞,繼承上一次迭代的路徑:

  1. 建立一個空列表 list 用於儲存成功的攻擊樣本。
  2. 進行 t 次迭代(i 從 1 到 t):
    • 2.1 對每個輸入的句子(j 從 1 到 n)執行以下操作:
      • 2.1.1 從句子 j 中隨機選擇 m 個詞。
      • 2.1.2 對於每個選擇的詞(k 從 1 到 m),執行以下步驟:
        • a. 找到詞 k 的不超過 p 個近義詞。
        • b. 對於每個近義詞(l 從 1 到 p),執行以下操作:
          • b.1 計算將詞 k 替換為近義詞 l 後,模型輸出的 logits 絕對值的變化量。
          • b.2 判斷模型的分類結果:
            • 如果模型的分類結果發生變化且相似度檢驗有效,則標記攻擊成功(break),將該句子新增到 list,從原句表中移除該句子。
            • 如果沒有分類變化但發現更大的變化量,則更新當前最大的變化量。
        • c. 比較每個詞 k 的最大 logits 變化量,選擇變化量最大的一個詞進行替換。
      • 2.1.3 對於沒有攻擊成功的句子 j,將其替換詞更新到表中,等待下一次迭代。
  3. 重複步驟 2,直到達到 t 次迭代或句表為空為止。

其中 logits 指的是模型最後一層的輸出,即每個標籤的機率分佈。

這正是“爬山”的具體體現:每次迭代向鄰域區域性最優(logits 變化量最大)的方向前進,形成一條“路徑”,直到上限。

當然,這和標準的爬山演算法有一些細微的差異,因為這個情境下的鄰域是隨機選出來的,而不是全部鄰域,這可以降低陷入區域性最優的機率。

climb.py (98% 45s)
import pandas as pd
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from sklearn.metrics.pairwise import cosine_similarity
import nltk
from nltk.corpus import wordnet
import random
import time

nltk.download('wordnet')

NUM_ITERATIONS = 5
SIMILARITY_THRESHOLD = 0.75
MAX_WORDS_TO_SELECT = 10
MAX_SYNONYMS_TO_SELECT = 10

csv_path = 'original_text.csv'
data = pd.read_csv(csv_path)

model_path = 'Sentiment_classification_model'
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForSequenceClassification.from_pretrained(model_path)

def calculate_logits_change(original_logits, new_logits):
    return torch.abs(original_logits - new_logits).sum().item()

def synonym_replacement(text, model, tokenizer):
    words = text.split()
    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    
    with torch.no_grad():
        original_logits = model(**original_encoding).logits
        original_prediction = original_logits.argmax(dim=1).item()

    max_change = 0
    best_text = text
    
    selected_indices = random.sample(range(len(words)), min(MAX_WORDS_TO_SELECT, len(words)))

    for i in selected_indices:
        synonyms = wordnet.synsets(words[i])
        if not synonyms:
            continue

        lemmas = set(lemma.name() for syn in synonyms for lemma in syn.lemmas() if lemma.name() != words[i])
        selected_synonyms = random.sample(list(lemmas), min(MAX_SYNONYMS_TO_SELECT, len(lemmas)))

        best_word_change = 0
        best_word_text = text

        for synonym in selected_synonyms:
            new_words = words.copy()
            new_words[i] = synonym
            new_text = ' '.join(new_words)

            new_encoding = tokenizer(new_text, return_tensors='pt', padding=True, truncation=True, max_length=512)
            with torch.no_grad():
                new_logits = model(**new_encoding).logits
                new_prediction = new_logits.argmax(dim=1).item()
            
            logits_change = calculate_logits_change(original_logits, new_logits)

            if new_prediction != original_prediction:
                similarity = verify_similarity(text, new_text, model, tokenizer)
                if similarity >= SIMILARITY_THRESHOLD:
                    return new_text

            if logits_change > best_word_change:
                best_word_change = logits_change
                best_word_text = new_text

        if best_word_change > max_change:
            max_change = best_word_change
            best_text = best_word_text

    return best_text

def verify_similarity(original, modified, model, tokenizer):
    model.eval()
    original_encoding = tokenizer(original, return_tensors='pt', padding=True, truncation=True, max_length=512)
    modified_encoding = tokenizer(modified, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_outputs = model.base_model(**original_encoding)
        original_hidden_state = original_outputs.last_hidden_state.mean(dim=1)

        modified_outputs = model.base_model(**modified_encoding)
        modified_hidden_state = modified_outputs.last_hidden_state.mean(dim=1)

    similarity = cosine_similarity(original_hidden_state.cpu().numpy(),
                                   modified_hidden_state.cpu().numpy())[0][0]

    return similarity

def check_attack_success(text, attacked_text, model, tokenizer):
    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    attacked_encoding = tokenizer(attacked_text, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_prediction = model(**original_encoding).logits.argmax(dim=1).item()
        attacked_prediction = model(**attacked_encoding).logits.argmax(dim=1).item()

    return original_prediction != attacked_prediction

start_time = time.time()

attacked_texts = []
remaining_data = data.copy()

for iteration in range(NUM_ITERATIONS):
    print(f"Starting iteration {iteration + 1}...")
    successful_attacks = []
    to_remove = []
    updated_remaining_data = []

    for index, row in remaining_data.iterrows():
        original_text = row['text']
        attacked_text = synonym_replacement(original_text, model, tokenizer)

        if attacked_text != original_text:
            similarity = verify_similarity(original_text, attacked_text, model, tokenizer)
            if similarity >= SIMILARITY_THRESHOLD:
                attack_success = check_attack_success(original_text, attacked_text, model, tokenizer)
                if attack_success:
                    successful_attacks.append((row['id'], attacked_text))
                    to_remove.append(index)
                else:
                    updated_remaining_data.append({'id': row['id'], 'text': attacked_text})
            else:
                updated_remaining_data.append({'id': row['id'], 'text': original_text})
        else:
            updated_remaining_data.append({'id': row['id'], 'text': original_text})

    attacked_texts.extend(successful_attacks)
    remaining_data = pd.DataFrame(updated_remaining_data)

    print(f"Iteration {iteration + 1}: {len(successful_attacks)} successful attacks.")

    if remaining_data.empty:
        print("All texts successfully attacked, ending early.")
        break

success_rate = len(attacked_texts) / len(data)
print(f'Final attack success rate: {success_rate * 100:.2f}%')

for index, row in remaining_data.iterrows():
    attacked_texts.append((row['id'], row['text']))

attacked_texts.sort(key=lambda x: x[0])
output_df = pd.DataFrame(attacked_texts, columns=['id', 'attacked_text'])
output_df.to_csv('attacked_text.csv', index=False)

end_time = time.time()
total_time = end_time - start_time
print(f"Total running time: {total_time:.2f} seconds")

Take 3 隨機+退火

退火是對爬山的一種變形,它會以一定機率向一個鄰域中更差的方向前進,更不容易陷入區域性最優。

假設一次迴圈中,當前最佳的子狀態 \(E^*=E_1\)\(E_2\) 是下一個子狀態。若 \(\Delta E=E_2-E_1>0\) 則一定令 \(E^*=E_2\);若 \(\Delta E\leq 0\) 則根據 \(\displaystyle{P=\text e^{\frac{\Delta E}{T}}}\) 得到令 \(E^*=E_2\) 的機率。

這裡的 \(T\) 指的是溫度,通常在一次迭代後令 \(T = \alpha T\)\(\alpha\) 是降溫係數。

結合退火的演算法流程:

  1. 建立一個空列表 list 用於儲存成功的攻擊樣本,初始化溫度 T 和降溫係數 alpha
  2. 進行 t 次迭代(i 從 1 到 t):
    • 2.1 對每個輸入的句子(j 從 1 到 n)執行以下操作:
      • 2.1.1 從句子 j 中隨機選擇 m 個詞。
      • 2.1.2 對於每個選擇的詞(k 從 1 到 m),執行以下步驟:
        • a. 找到詞 k 的不超過 p 個近義詞。
        • b. 對於每個近義詞(l 從 1 到 p),執行以下操作:
          • b.1 計算將詞 k 替換為近義詞 l 後,模型輸出的 logits 絕對值的變化量 ΔE
          • b.2 判斷模型的分類結果:
            • 如果模型的分類結果發生變化且相似度檢驗有效,則標記攻擊成功(break),將該句子新增到 list,從原句表中移除該句子。
            • 如果沒有分類變化,則根據退火公式檢查替換條件:
              • 如果 ΔE > 0,直接接受這個替換,將替換後的句子更新為當前句子。
              • 如果 ΔE <= 0,計算接受機率 P = exp(ΔE / T),以機率 P 接受替換。
        • c. 比較每個詞 kΔE 值,根據退火公式選擇一個詞替換。
      • 2.1.3 對於沒有攻擊成功的句子 j,將其替換詞更新到表中,等待下一次迭代。
    • 2.2 更新溫度 T = alpha * T
  3. 重複步驟 2,直到達到 t 次迭代或句表為空為止。
anneal.py (94% 47s)
import pandas as pd
import torch
from transformers import AutoTokenizer, AutoModelForSequenceClassification
from sklearn.metrics.pairwise import cosine_similarity
import nltk
from nltk.corpus import wordnet
import random
import math
import time

nltk.download('wordnet')

NUM_ITERATIONS = 5
SIMILARITY_THRESHOLD = 0.75
MAX_WORDS_TO_SELECT = 10
MAX_SYNONYMS_TO_SELECT = 10
INITIAL_TEMPERATURE = 1.0
ALPHA = 0.9

csv_path = 'original_text.csv'
data = pd.read_csv(csv_path)
model_path = 'Sentiment_classification_model'
tokenizer = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForSequenceClassification.from_pretrained(model_path)

def calculate_logits_change(original_logits, new_logits):
    return torch.abs(original_logits - new_logits).sum().item()

def synonym_replacement(text, model, tokenizer, temperature):
    words = text.split()
    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    
    with torch.no_grad():
        original_logits = model(**original_encoding).logits
        original_prediction = original_logits.argmax(dim=1).item()

    selected_indices = random.sample(range(len(words)), min(MAX_WORDS_TO_SELECT, len(words)))
    
    for i in selected_indices:
        synonyms = wordnet.synsets(words[i])
        if not synonyms:
            continue

        lemmas = set(lemma.name() for syn in synonyms for lemma in syn.lemmas() if lemma.name() != words[i])
        selected_synonyms = random.sample(list(lemmas), min(MAX_SYNONYMS_TO_SELECT, len(lemmas)))

        best_word_change = 0
        best_word_text = text

        for synonym in selected_synonyms:
            new_words = words.copy()
            new_words[i] = synonym
            new_text = ' '.join(new_words)

            new_encoding = tokenizer(new_text, return_tensors='pt', padding=True, truncation=True, max_length=512)
            with torch.no_grad():
                new_logits = model(**new_encoding).logits
                new_prediction = new_logits.argmax(dim=1).item()
            
            logits_change = calculate_logits_change(original_logits, new_logits)

            if new_prediction != original_prediction:
                similarity = verify_similarity(text, new_text, model, tokenizer)
                if similarity >= SIMILARITY_THRESHOLD:
                    return new_text

            if logits_change > best_word_change:
                best_word_change = logits_change
                best_word_text = new_text
            else:
                acceptance_probability = math.exp(logits_change / temperature)
                if random.random() < acceptance_probability:
                    best_word_change = logits_change
                    best_word_text = new_text

        text = best_word_text

    return text

def verify_similarity(original, modified, model, tokenizer):
    model.eval()
    original_encoding = tokenizer(original, return_tensors='pt', padding=True, truncation=True, max_length=512)
    modified_encoding = tokenizer(modified, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_outputs = model.base_model(**original_encoding)
        original_hidden_state = original_outputs.last_hidden_state.mean(dim=1)

        modified_outputs = model.base_model(**modified_encoding)
        modified_hidden_state = modified_outputs.last_hidden_state.mean(dim=1)

    similarity = cosine_similarity(original_hidden_state.cpu().numpy(),
                                   modified_hidden_state.cpu().numpy())[0][0]

    return similarity

def check_attack_success(text, attacked_text, model, tokenizer):
    original_encoding = tokenizer(text, return_tensors='pt', padding=True, truncation=True, max_length=512)
    attacked_encoding = tokenizer(attacked_text, return_tensors='pt', padding=True, truncation=True, max_length=512)

    with torch.no_grad():
        original_prediction = model(**original_encoding).logits.argmax(dim=1).item()
        attacked_prediction = model(**attacked_encoding).logits.argmax(dim=1).item()

    return original_prediction != attacked_prediction

start_time = time.time()

attacked_texts = []
remaining_data = data.copy()
temperature = INITIAL_TEMPERATURE

for iteration in range(NUM_ITERATIONS):
    print(f"Starting iteration {iteration + 1}...")
    successful_attacks = []
    to_remove = []
    updated_remaining_data = []

    for index, row in remaining_data.iterrows():
        original_text = row['text']
        attacked_text = synonym_replacement(original_text, model, tokenizer, temperature)

        if attacked_text != original_text:
            similarity = verify_similarity(original_text, attacked_text, model, tokenizer)
            if similarity >= SIMILARITY_THRESHOLD:
                attack_success = check_attack_success(original_text, attacked_text, model, tokenizer)
                if attack_success:
                    successful_attacks.append((row['id'], attacked_text))
                    to_remove.append(index)
                else:
                    updated_remaining_data.append({'id': row['id'], 'text': attacked_text})
            else:
                updated_remaining_data.append({'id': row['id'], 'text': original_text})
        else:
            updated_remaining_data.append({'id': row['id'], 'text': original_text})

    attacked_texts.extend(successful_attacks)
    remaining_data = pd.DataFrame(updated_remaining_data)

    print(f"Iteration {iteration + 1}: {len(successful_attacks)} successful attacks.")

    if remaining_data.empty:
        print("All texts successfully attacked, ending early.")
        break

    temperature *= ALPHA

success_rate = len(attacked_texts) / len(data)
print(f'Final attack success rate: {success_rate * 100:.2f}%')

for index, row in remaining_data.iterrows():
    attacked_texts.append((row['id'], row['text']))

attacked_texts.sort(key=lambda x: x[0])
output_df = pd.DataFrame(attacked_texts, columns=['id', 'attacked_text'])
output_df.to_csv('attacked_text.csv', index=False)

end_time = time.time()
total_time = end_time - start_time
print(f"Total running time: {total_time:.2f} seconds")

相關文章