開源 AI 食譜文件

在單塊 GPU 上用自定義程式碼微調程式碼大語言模型

Hugging Face's logo
加入 Hugging Face 社群

並獲得增強的文件體驗

開始使用

Open In Colab

在單塊 GPU 上用自定義程式碼微調程式碼大語言模型

作者:Maria Khalusova

公開可用的程式碼大語言模型 (Code LLM),如 Codex、StarCoder 和 Code Llama,在生成遵循通用程式設計原則和語法的程式碼方面表現出色,但它們可能不符合組織的內部慣例,或者不知道專有庫。

在本 notebook 中,我們將展示如何利用私有程式碼庫對程式碼大語言模型進行微調,以增強其上下文感知能力,並提高模型對組織需求的實用性。由於程式碼大語言模型相當大,以傳統方式進行微調可能會耗費大量資源。別擔心!我們將展示如何最佳化微調過程,使其能夠在單塊 GPU 上執行。

資料集

本示例中,我們選擇了 GitHub 上排名前 10 的 Hugging Face 公開倉庫。我們排除了資料中的非程式碼檔案,如影像、音訊檔案、簡報等。對於 Jupyter notebook,我們只保留了包含程式碼的單元格。最終的程式碼以資料集的形式儲存,你可以在 Hugging Face Hub 的 smangrul/hf-stack-v1 下找到它。它包含倉庫 ID、檔案路徑和檔案內容。

模型

我們將微調 bigcode/starcoderbase-1b,這是一個擁有 10 億引數、在 80 多種程式語言上訓練過的模型。這是一個受限模型,因此如果你打算使用這個確切的模型執行此 notebook,你需要在模型頁面上申請訪問許可權。請登入你的 Hugging Face 賬戶以進行操作。

from huggingface_hub import notebook_login

notebook_login()

首先,讓我們安裝所有必要的庫。如你所見,除了 transformersdatasets,我們還將使用 peftbitsandbytesflash-attn 來最佳化訓練過程。

透過採用引數高效訓練技術,我們可以在單塊 A100 高視訊記憶體 GPU 上執行此 notebook。

!pip install -q transformers datasets peft bitsandbytes flash-attn

現在我們來定義一些變數。你可以隨意調整這些值。

MODEL = "bigcode/starcoderbase-1b"  # Model checkpoint on the Hugging Face Hub
DATASET = "smangrul/hf-stack-v1"  # Dataset on the Hugging Face Hub
DATA_COLUMN = "content"  # Column name containing the code content

SEQ_LENGTH = 2048  # Sequence length

# Training arguments
MAX_STEPS = 2000  # max_steps
BATCH_SIZE = 16  # batch_size
GR_ACC_STEPS = 1  # gradient_accumulation_steps
LR = 5e-4  # learning_rate
LR_SCHEDULER_TYPE = "cosine"  # lr_scheduler_type
WEIGHT_DECAY = 0.01  # weight_decay
NUM_WARMUP_STEPS = 30  # num_warmup_steps
EVAL_FREQ = 100  # eval_freq
SAVE_FREQ = 100  # save_freq
LOG_FREQ = 25  # log_freq
OUTPUT_DIR = "peft-starcoder-lora-a100"  # output_dir
BF16 = True  # bf16
FP16 = False  # no_fp16

# FIM trasformations arguments
FIM_RATE = 0.5  # fim_rate
FIM_SPM_RATE = 0.5  # fim_spm_rate

# LORA
LORA_R = 8  # lora_r
LORA_ALPHA = 32  # lora_alpha
LORA_DROPOUT = 0.0  # lora_dropout
LORA_TARGET_MODULES = "c_proj,c_attn,q_attn,c_fc,c_proj"  # lora_target_modules

# bitsandbytes config
USE_NESTED_QUANT = True  # use_nested_quant
BNB_4BIT_COMPUTE_DTYPE = "bfloat16"  # bnb_4bit_compute_dtype

SEED = 0
from transformers import (
    AutoModelForCausalLM,
    AutoTokenizer,
    Trainer,
    TrainingArguments,
    logging,
    set_seed,
    BitsAndBytesConfig,
)

set_seed(SEED)

準備資料

首先載入資料。由於資料集可能很大,請確保啟用流式模式 (streaming mode)。流式載入允許我們在遍歷資料集時逐步載入資料,而不是一次性下載整個資料集。

我們將保留前 4000 個樣本作為驗證集,其餘的將作為訓練資料。

from datasets import load_dataset
import torch
from tqdm import tqdm


dataset = load_dataset(
    DATASET,
    data_dir="data",
    split="train",
    streaming=True,
)

valid_data = dataset.take(4000)
train_data = dataset.skip(4000)
train_data = train_data.shuffle(buffer_size=5000, seed=SEED)

在這一步,資料集仍然包含任意長度程式碼的原始資料。對於訓練,我們需要固定長度的輸入。讓我們建立一個可迭代資料集 (Iterable dataset),它能從文字檔案流中返回固定長度的詞元 (token) 塊。

首先,我們來估算資料集中每個詞元的平均字元數,這將有助於我們稍後估算文字緩衝區中的詞元數量。預設情況下,我們只從資料集中取 400 個樣本 (nb_examples)。僅使用整個資料集的一個子集將減少計算成本,同時仍能提供對整體字元與詞元比率的合理估計。

>>> tokenizer = AutoTokenizer.from_pretrained(MODEL, trust_remote_code=True)


>>> def chars_token_ratio(dataset, tokenizer, data_column, nb_examples=400):
...     """
...     Estimate the average number of characters per token in the dataset.
...     """

...     total_characters, total_tokens = 0, 0
...     for _, example in tqdm(zip(range(nb_examples), iter(dataset)), total=nb_examples):
...         total_characters += len(example[data_column])
...         total_tokens += len(tokenizer(example[data_column]).tokens())

...     return total_characters / total_tokens


>>> chars_per_token = chars_token_ratio(train_data, tokenizer, DATA_COLUMN)
>>> print(f"The character to token ratio of the dataset is: {chars_per_token:.2f}")
The character to token ratio of the dataset is: 2.43

字元與詞元比率也可以用作衡量文字分詞質量的指標。例如,字元與詞元比率為 1.0 意味著每個字元都被表示為一個詞元,這沒有多大意義,表明分詞效果很差。在標準英語文字中,一個詞元通常約等於四個字元,即字元與詞元比率約為 4.0。我們可以預期程式碼資料集中的比率會更低,但一般來說,2.0 到 3.5 之間的數值可以被認為是足夠好的。

可選的 FIM 轉換

自迴歸語言模型通常從左到右生成序列。透過應用 FIM (Fill-in-the-Middle, 中間填充) 轉換,模型也可以學習填充文字。請查閱 “高效訓練語言模型以填充中間內容” (Efficient Training of Language Models to Fill in the Middle) 論文以瞭解更多關於該技術的資訊。我們將在這裡定義 FIM 轉換,並在建立可迭代資料集時使用它們。但是,如果你想省略轉換,可以隨時將 fim_rate 設定為 0。

import functools
import numpy as np


# Helper function to get token ids of the special tokens for prefix, suffix and middle for FIM transformations.
@functools.lru_cache(maxsize=None)
def get_fim_token_ids(tokenizer):
    try:
        FIM_PREFIX, FIM_MIDDLE, FIM_SUFFIX, FIM_PAD = tokenizer.special_tokens_map["additional_special_tokens"][1:5]
        suffix_tok_id, prefix_tok_id, middle_tok_id, pad_tok_id = (
            tokenizer.vocab[tok] for tok in [FIM_SUFFIX, FIM_PREFIX, FIM_MIDDLE, FIM_PAD]
        )
    except KeyError:
        suffix_tok_id, prefix_tok_id, middle_tok_id, pad_tok_id = None, None, None, None
    return suffix_tok_id, prefix_tok_id, middle_tok_id, pad_tok_id


## Adapted from https://github.com/bigcode-project/Megatron-LM/blob/6c4bf908df8fd86b4977f54bf5b8bd4b521003d1/megatron/data/gpt_dataset.py
def permute(
    sample,
    np_rng,
    suffix_tok_id,
    prefix_tok_id,
    middle_tok_id,
    pad_tok_id,
    fim_rate=0.5,
    fim_spm_rate=0.5,
    truncate_or_pad=False,
):
    """
    Take in a sample (list of tokens) and perform a FIM transformation on it with a probability of fim_rate, using two FIM modes:
    PSM and SPM (with a probability of fim_spm_rate).
    """

    # The if condition will trigger with the probability of fim_rate
    # This means FIM transformations will apply to samples with a probability of fim_rate
    if np_rng.binomial(1, fim_rate):

        # Split the sample into prefix, middle, and suffix, based on randomly generated indices stored in the boundaries list.
        boundaries = list(np_rng.randint(low=0, high=len(sample) + 1, size=2))
        boundaries.sort()

        prefix = np.array(sample[: boundaries[0]], dtype=np.int64)
        middle = np.array(sample[boundaries[0] : boundaries[1]], dtype=np.int64)
        suffix = np.array(sample[boundaries[1] :], dtype=np.int64)

        if truncate_or_pad:
            # calculate the new total length of the sample, taking into account tokens indicating prefix, middle, and suffix
            new_length = suffix.shape[0] + prefix.shape[0] + middle.shape[0] + 3
            diff = new_length - len(sample)

            # trancate or pad if there's a difference in length between the new length and the original
            if diff > 0:
                if suffix.shape[0] <= diff:
                    return sample, np_rng
                suffix = suffix[: suffix.shape[0] - diff]
            elif diff < 0:
                suffix = np.concatenate([suffix, np.full((-1 * diff), pad_tok_id)])

        # With the probability of fim_spm_rateapply SPM variant of FIM transformations
        # SPM: suffix, prefix, middle
        if np_rng.binomial(1, fim_spm_rate):
            new_sample = np.concatenate(
                [
                    [prefix_tok_id, suffix_tok_id],
                    suffix,
                    [middle_tok_id],
                    prefix,
                    middle,
                ]
            )
        # Otherwise, apply the PSM variant of FIM transformations
        # PSM: prefix, suffix, middle
        else:

            new_sample = np.concatenate(
                [
                    [prefix_tok_id],
                    prefix,
                    [suffix_tok_id],
                    suffix,
                    [middle_tok_id],
                    middle,
                ]
            )
    else:
        # don't apply FIM transformations
        new_sample = sample

    return list(new_sample), np_rng

讓我們定義 ConstantLengthDataset,這是一個可迭代的資料集,它將返回固定長度的詞元塊。為此,我們將從原始資料集中讀取一個文字緩衝區,直到達到大小限制,然後應用分詞器將原始文字轉換為分詞後的輸入。我們還可以選擇性地對某些序列執行 FIM 轉換 (受影響序列的比例由 fim_rate 控制)。

定義完成後,我們可以從訓練資料和驗證資料中建立 ConstantLengthDataset 的例項。

from torch.utils.data import IterableDataset
from torch.utils.data.dataloader import DataLoader
import random

# Create an Iterable dataset that returns constant-length chunks of tokens from a stream of text files.


class ConstantLengthDataset(IterableDataset):
    """
    Iterable dataset that returns constant length chunks of tokens from stream of text files.
        Args:
            tokenizer (Tokenizer): The processor used for proccessing the data.
            dataset (dataset.Dataset): Dataset with text files.
            infinite (bool): If True the iterator is reset after dataset reaches end else stops.
            seq_length (int): Length of token sequences to return.
            num_of_sequences (int): Number of token sequences to keep in buffer.
            chars_per_token (int): Number of characters per token used to estimate number of tokens in text buffer.
            fim_rate (float): Rate (0.0 to 1.0) that sample will be permuted with FIM.
            fim_spm_rate (float): Rate (0.0 to 1.0) of FIM permuations that will use SPM.
            seed (int): Seed for random number generator.
    """

    def __init__(
        self,
        tokenizer,
        dataset,
        infinite=False,
        seq_length=1024,
        num_of_sequences=1024,
        chars_per_token=3.6,
        content_field="content",
        fim_rate=0.5,
        fim_spm_rate=0.5,
        seed=0,
    ):
        self.tokenizer = tokenizer
        self.concat_token_id = tokenizer.eos_token_id
        self.dataset = dataset
        self.seq_length = seq_length
        self.infinite = infinite
        self.current_size = 0
        self.max_buffer_size = seq_length * chars_per_token * num_of_sequences
        self.content_field = content_field
        self.fim_rate = fim_rate
        self.fim_spm_rate = fim_spm_rate
        self.seed = seed

        (
            self.suffix_tok_id,
            self.prefix_tok_id,
            self.middle_tok_id,
            self.pad_tok_id,
        ) = get_fim_token_ids(self.tokenizer)
        if not self.suffix_tok_id and self.fim_rate > 0:
            print("FIM is not supported by tokenizer, disabling FIM")
            self.fim_rate = 0

    def __iter__(self):
        iterator = iter(self.dataset)
        more_examples = True
        np_rng = np.random.RandomState(seed=self.seed)
        while more_examples:
            buffer, buffer_len = [], 0
            while True:
                if buffer_len >= self.max_buffer_size:
                    break
                try:
                    buffer.append(next(iterator)[self.content_field])
                    buffer_len += len(buffer[-1])
                except StopIteration:
                    if self.infinite:
                        iterator = iter(self.dataset)
                    else:
                        more_examples = False
                        break
            tokenized_inputs = self.tokenizer(buffer, truncation=False)["input_ids"]
            all_token_ids = []

            for tokenized_input in tokenized_inputs:
                # optionally do FIM permutations
                if self.fim_rate > 0:
                    tokenized_input, np_rng = permute(
                        tokenized_input,
                        np_rng,
                        self.suffix_tok_id,
                        self.prefix_tok_id,
                        self.middle_tok_id,
                        self.pad_tok_id,
                        fim_rate=self.fim_rate,
                        fim_spm_rate=self.fim_spm_rate,
                        truncate_or_pad=False,
                    )

                all_token_ids.extend(tokenized_input + [self.concat_token_id])
            examples = []
            for i in range(0, len(all_token_ids), self.seq_length):
                input_ids = all_token_ids[i : i + self.seq_length]
                if len(input_ids) == self.seq_length:
                    examples.append(input_ids)
            random.shuffle(examples)
            for example in examples:
                self.current_size += 1
                yield {
                    "input_ids": torch.LongTensor(example),
                    "labels": torch.LongTensor(example),
                }


train_dataset = ConstantLengthDataset(
    tokenizer,
    train_data,
    infinite=True,
    seq_length=SEQ_LENGTH,
    chars_per_token=chars_per_token,
    content_field=DATA_COLUMN,
    fim_rate=FIM_RATE,
    fim_spm_rate=FIM_SPM_RATE,
    seed=SEED,
)
eval_dataset = ConstantLengthDataset(
    tokenizer,
    valid_data,
    infinite=False,
    seq_length=SEQ_LENGTH,
    chars_per_token=chars_per_token,
    content_field=DATA_COLUMN,
    fim_rate=FIM_RATE,
    fim_spm_rate=FIM_SPM_RATE,
    seed=SEED,
)

準備模型

既然資料已經準備好了,是時候載入模型了!我們將載入模型的量化版本。

這將使我們能夠減少記憶體使用,因為量化用更少的位元表示資料。我們將使用 bitsandbytes 庫來量化模型,因為它與 transformers 有很好的整合。我們只需要定義一個 bitsandbytes 配置,然後在載入模型時使用它。

4 位量化有不同的變體,但總的來說,我們推薦使用 NF4 量化以獲得更好的效能 (bnb_4bit_quant_type="nf4")。

bnb_4bit_use_double_quant 選項在第一次量化後增加第二次量化,以每個引數額外節省 0.4 位元。

要了解更多關於量化的資訊,請檢視 “透過 bitsandbytes、4 位量化和 QLoRA 讓大語言模型更易於使用” (Making LLMs even more accessible with bitsandbytes, 4-bit quantization and QLoRA) 這篇部落格文章

定義好配置後,將其傳遞給 from_pretrained 方法以載入模型的量化版本。

from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from peft.tuners.lora import LoraLayer

load_in_8bit = False

# 4-bit quantization
compute_dtype = getattr(torch, BNB_4BIT_COMPUTE_DTYPE)

bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=compute_dtype,
    bnb_4bit_use_double_quant=USE_NESTED_QUANT,
)

device_map = {"": 0}

model = AutoModelForCausalLM.from_pretrained(
    MODEL,
    load_in_8bit=load_in_8bit,
    quantization_config=bnb_config,
    device_map=device_map,
    use_cache=False,  # We will be using gradient checkpointing
    trust_remote_code=True,
    use_flash_attention_2=True,
)

當使用量化模型進行訓練時,你需要呼叫 prepare_model_for_kbit_training() 函式來對量化模型進行訓練前的預處理。

model = prepare_model_for_kbit_training(model)

現在量化模型已經準備好,我們可以設定 LoRA 配置了。LoRA 透過大幅減少可訓練引數的數量,使微調更加高效。

要使用 LoRA 技術訓練模型,我們需要將基礎模型包裝成 PeftModel。這包括使用 LoraConfig 定義 LoRA 配置,並使用該 LoraConfig 透過 get_peft_model() 包裝原始模型。

要了解更多關於 LoRA 及其引數的資訊,請參閱 PEFT 文件

>>> # Set up lora
>>> peft_config = LoraConfig(
...     lora_alpha=LORA_ALPHA,
...     lora_dropout=LORA_DROPOUT,
...     r=LORA_R,
...     bias="none",
...     task_type="CAUSAL_LM",
...     target_modules=LORA_TARGET_MODULES.split(","),
... )

>>> model = get_peft_model(model, peft_config)
>>> model.print_trainable_parameters()
trainable params: 5,554,176 || all params: 1,142,761,472 || trainable%: 0.4860310866343243

正如你所見,透過應用 LoRA 技術,我們現在需要訓練的引數不到 1%。

訓練模型

現在我們已經準備好了資料,並優化了模型,我們準備好將所有東西整合起來開始訓練了。

要例項化一個 Trainer,你需要定義訓練配置。最重要的是 TrainingArguments,它是一個包含所有用於配置訓練的屬性的類。

這些引數與你可能執行的任何其他型別的模型訓練相似,所以我們在這裡不作詳細介紹。

train_data.start_iteration = 0


training_args = TrainingArguments(
    output_dir=f"Your_HF_username/{OUTPUT_DIR}",
    dataloader_drop_last=True,
    evaluation_strategy="steps",
    save_strategy="steps",
    max_steps=MAX_STEPS,
    eval_steps=EVAL_FREQ,
    save_steps=SAVE_FREQ,
    logging_steps=LOG_FREQ,
    per_device_train_batch_size=BATCH_SIZE,
    per_device_eval_batch_size=BATCH_SIZE,
    learning_rate=LR,
    lr_scheduler_type=LR_SCHEDULER_TYPE,
    warmup_steps=NUM_WARMUP_STEPS,
    gradient_accumulation_steps=GR_ACC_STEPS,
    gradient_checkpointing=True,
    fp16=FP16,
    bf16=BF16,
    weight_decay=WEIGHT_DECAY,
    push_to_hub=True,
    include_tokens_per_second=True,
)

作為最後一步,例項化 Trainer 並呼叫 train 方法。

>>> trainer = Trainer(model=model, args=training_args, train_dataset=train_dataset, eval_dataset=eval_dataset)

>>> print("Training...")
>>> trainer.train()
Training...

最後,你可以將微調後的模型推送到你的 Hub 倉庫,與你的團隊分享。

trainer.push_to_hub()

推理

一旦模型上傳到 Hub,我們就可以用它進行推理。為此,我們首先初始化原始的基礎模型及其分詞器。接下來,我們需要將微調後的權重與基礎模型合併。

from peft import PeftModel
import torch

# load the original model first
tokenizer = AutoTokenizer.from_pretrained(MODEL, trust_remote_code=True)
base_model = AutoModelForCausalLM.from_pretrained(
    MODEL,
    quantization_config=None,
    device_map=None,
    trust_remote_code=True,
    torch_dtype=torch.bfloat16,
).cuda()

# merge fine-tuned weights with the base model
peft_model_id = f"Your_HF_username/{OUTPUT_DIR}"
model = PeftModel.from_pretrained(base_model, peft_model_id)
model.merge_and_unload()

現在我們可以使用合併後的模型進行推理。為方便起見,我們將定義一個 get_code_completion 函式——你可以隨意嘗試不同的文字生成引數!

def get_code_completion(prefix, suffix):
    text = prompt = f"""<fim_prefix>{prefix}<fim_suffix>{suffix}<fim_middle>"""
    model.eval()
    outputs = model.generate(
        input_ids=tokenizer(text, return_tensors="pt").input_ids.cuda(),
        max_new_tokens=128,
        temperature=0.2,
        top_k=50,
        top_p=0.95,
        do_sample=True,
        repetition_penalty=1.0,
    )
    return tokenizer.batch_decode(outputs, skip_special_tokens=True)[0]

現在,要獲得程式碼補全,我們只需要呼叫 get_code_complete 函式,並將我們想要補全的前幾行作為字首 (prefix) 傳入,並將一個空字串作為字尾 (suffix) 傳入。

>>> prefix = """from peft import LoraConfig, TaskType, get_peft_model
... from transformers import AutoModelForCausalLM
... peft_config = LoraConfig(
... """
>>> suffix = """"""

... print(get_code_completion(prefix, suffix))
from peft import LoraConfig, TaskType, get_peft_model
from transformers import AutoModelForCausalLM
peft_config = LoraConfig(
    task_type=TaskType.CAUSAL_LM,
    r=8,
    lora_alpha=32,
    target_modules=["q_proj", "v_proj"],
    lora_dropout=0.1,
    bias="none",
    modules_to_save=["q_proj", "v_proj"],
    inference_mode=False,
)
model = AutoModelForCausalLM.from_pretrained("gpt2")
model = get_peft_model(model, peft_config)
model.print_trainable_parameters()

作為一個剛剛在本 notebook 前面部分使用過 PEFT 庫的人,你可以看到為建立 LoraConfig 生成的結果相當不錯!

如果你回到我們例項化推理模型的單元格,並註釋掉合併微調權重的程式碼行,你可以看到原始模型對完全相同的字首會生成什麼內容。

>>> prefix = """from peft import LoraConfig, TaskType, get_peft_model
... from transformers import AutoModelForCausalLM
... peft_config = LoraConfig(
... """
>>> suffix = """"""

... print(get_code_completion(prefix, suffix))
from peft import LoraConfig, TaskType, get_peft_model
from transformers import AutoModelForCausalLM
peft_config = LoraConfig(
    model_name_or_path="facebook/wav2vec2-base-960h",
    num_labels=1,
    num_features=1,
    num_hidden_layers=1,
    num_attention_heads=1,
    num_hidden_layers_per_attention_head=1,
    num_attention_heads_per_hidden_layer=1,
    hidden_size=1024,
    hidden_dropout_prob=0.1,
    hidden_act="gelu",
    hidden_act_dropout_prob=0.1,
    hidden

雖然它符合 Python 語法,但你可以看到原始模型並不理解 LoraConfig 應該做什麼。

要了解這種微調與完全微調的比較,以及如何透過推理端點 (Inference Endpoints) 或在本地將這樣的模型用作你在 VS Code 中的程式設計助手 (copilot),請檢視 “個人程式設計助手:訓練你自己的編碼助手” (Personal Copilot: Train Your Own Coding Assistant) 這篇部落格文章。本 notebook 是對原文的補充。

< > 在 GitHub 上更新

© . This site is unofficial and not affiliated with Hugging Face, Inc.