Skip to content

Laboratório 11 - Inferência e Fine-Tuning de LLMs

Neste laboratório, vamos mergulhar no fascinante mundo da geração de texto usando Large Language Models (LLMs). O objetivo principal é desvendar o funcionamento dessas poderosas ferramentas e explorar conceitos-chave como tokenização, temperatura do modelo e otimização com KV-Cache.

Objetivos:

Ao final deste laboratório, os alunos serão capazes de:

  • Entender o processo de tokenização e como ele influencia a geração de texto.
  • Manipular a temperatura do modelo para controlar a criatividade e aleatoriedade do texto gerado.
  • Implementar a otimização KV-Cache para acelerar a geração de texto.
  • Avaliar a performance de diferentes modelos de linguagem.
  • Aplicar LLMs em tarefas de geração de texto do mundo real.
import numpy as np
import time
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt
import time

torch.device('cuda')

Metodologia

Utilizaremos a biblioteca transformers do Hugging Face para interagir com modelos de linguagem pré-treinados, como o Llama 2 e o Qwen. Através de exemplos práticos e exercícios interativos, os alunos irão:

  • Carregar e inicializar modelos e tokenizadores.
  • Analisar o processo de tokenização de diferentes prompts.
  • Experimentar com diferentes valores de temperatura para controlar a geração de texto.
  • Implementar e avaliar o impacto do KV-Cache na performance.
  • Gerar sequências de texto e comparar os resultados de diferentes modelos.

Vamos definir uma função get_model_and_tokenizer que recebe o nome de um modelo como entrada e retorna o modelo e o tokenizador correspondentes, carregados do Hugging Face.

def get_model_and_tokenizer(model_name):
    model = AutoModelForCausalLM.from_pretrained(model_name)
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    return model, tokenizer

Em seguida, criamos uma função clear_memory para limpar a memória da GPU, liberando recursos para evitar problemas de alocação.

def clear_memory():
  torch.cuda.empty_cache()
  torch.cuda.reset_peak_memory_stats()

Carregando Modelos e Tokenizers

Nesse laboratório vamos utilizar os modelos Llama 2 e Qwen e seus tokenizadores. Para isso, vamos utilizar a função get_model_and_tokenizer, armazenando-os em variáveis.

llama_model, llama_tokenizer = get_model_and_tokenizer("unsloth/Llama-3.2-1B-Instruct")
qwen_model, qwen_tokenizer   = get_model_and_tokenizer("''unsloth/Qwen2.5-0.5B-Instruct''")
smol_model, smol_tokenizer   = get_model_and_tokenizer("unsloth/SmolLM-360M-Instruct")
gpt_model, gpt_tokenizer     = get_model_and_tokenizer("gpt2")

Vamos criar um dicionário para os modelos afim de armazenar os modelos e tokenizadores, facilitando o acesso posterior.

models = {
    "llama":  (llama_model, llama_tokenizer),
    "qwen" :  (qwen_model, qwen_tokenizer),
    "smol" :  (smol_model, smol_tokenizer),
    "gpt"  :  (gpt_model, gpt_tokenizer)
}

Explorando Tokenizadores

Definimos um prompt inicial e para ser tokenizado usando os tokenizadores de cada modelo, armazenando os tokens no dicionário tokens. Dessa forma será possível comparar os tokens e identificadores gerados por cada modelo

prompt = "Tell me a history about a"
tokens = {}

for model_name in models.keys():
  model, tokenizer = models[model_name]

  inputs             = tokenizer(prompt, return_tensors="pt")
  tokens[model_name] = inputs['input_ids']

tokens
{'llama': tensor([[128000,  41551,    757,    264,   3925,    922,    264]]),
 'qwen': tensor([[40451,   752,   264,  3840,   911,   264]]),
 'smol': tensor([[31530,   549,   253,  1463,   563,   253]]),
 'gpt': tensor([[24446,   502,   257,  2106,   546,   257]])}

Agora, vamos coletar as probabilidades de saída (logits) de cada modelo para o prompt, armazenando-as no dicionário outputs. Com isso será possível verificar os top-k gerados por cada modelo e também explorar variações com o parâmetro de temperatura.

outputs = {}

with torch.no_grad():
  for model_name in models.keys():
    model, tokenizer = models[model_name]
    inputs = tokenizer(prompt, return_tensors="pt")
    output = model(**inputs)

    outputs[model_name] = output.logits[0, -1, :]
outputs
{'llama': tensor([ 3.7055,  5.9532,  3.1113,  ..., -1.0744, -1.0748, -1.0746]),
 'qwen': tensor([ 1.6746,  4.0747,  0.9490,  ..., -3.4412, -3.4407, -3.4412]),
 'smol': tensor([ 0.4465, -7.9859, -7.8056,  ..., -4.4378, -2.5055, -4.7143]),
 'gpt': tensor([-94.5002, -92.3858, -97.2071,  ..., -98.2227, -98.7545, -93.1210])}

O código a seguir decodifica o próximo token previsto por cada modelo com base nos logits, exibindo o token e seu identificador.

for model_name in outputs.keys():
  model, tokenizer = models[model_name]
  last_logits      = outputs[model_name]
  next_token_id    = last_logits.argmax()
  print('='*30)
  print(f"Modelo                 : {model_name}")
  print(f"Identificador do Toker : {next_token_id}")
  print(f"Token decodificado     : {tokenizer.decode(next_token_id)}")
==============================
Modelo                 : llama
Identificador do Toker : 3230
Token decodificado     :  specific
==============================
Modelo                 : qwen
Identificador do Toker : 11245
Token decodificado     :  famous
==============================
Modelo                 : smol
Identificador do Toker : 1379
Token decodificado     :  place
==============================
Modelo                 : gpt
Identificador do Toker : 1048
Token decodificado     :  person

Visualizando a saída do Modelo

Para entender melhor a saída do modelo, vamos gerar visualizações com a probabilidade dada pelo modelo para cada token do vocabulário considerando o prompt de entrada. Assim, definimos uma função plot_probabilities para visualizar as probabilidades dos 10 tokens mais prováveis, variando a temperatura.

def plot_probabilities(logits, tokenizer, model_name, temp=0.7, ax=None):
  top_k  = torch.topk(last_logits, k=10)
  tokens = [tokenizer.decode(tk) for tk in top_k.indices]
  values = torch.nn.functional.softmax(top_k.values/temp, dim=-1)

  sns.barplot(y=values, x=tokens, ax=ax, ec='k', palette='magma', hue=tokens)
  ax.set_title(f"Temperatura: {temp}")
  ax.set_ylabel(f"Probabilidades - {model_name}")
  ax.grid(True, linestyle=':')
  ax.set_axisbelow(True)

Para plotar as probabilidade utilizamos o seguinte código:

fig, ax = plt.subplots(nrows=len(models), ncols=3, figsize=(20, 14))

for idx_model, model_name in enumerate(outputs.keys()):
  _, tokenizer = models[model_name]
  last_logits  = outputs[model_name]

  for idx_temp, temp in enumerate([0.7, 5, 10]):
    ax_ = ax[idx_model, idx_temp]
    plot_probabilities(last_logits, tokenizer, model_name, temp=temp, ax=ax_)

Dessa forma, podemos observar que o aumento da temperatura distribui a probabilidade mais igualmente para palavras que antes eram bem menos provaveis de ocorrerem. Enquanto isso, ela diminui a probabilidade de palavras que antes seriam dominantes na predição.

Gerando Sequências de Tokens

Vamos definir uma função generate_token para gerar o próximo token com base nas probabilidades do modelo.

def generate_token(inputs, model):
    with torch.no_grad():
        outputs = model(**inputs)

    logits        = outputs.logits
    last_logits   = logits[0, -1, :]
    next_token_id = last_logits.argmax()

    return next_token_id

Em seguida, definimos a função first_input para preparar a entrada para a geração de sequências de tokens utilizando a predição do modelo de forma sequêncial.

def first_input(model_name):
  next_inputs = {
      "input_ids": torch.cat(
          [tokens[model_name], next_token_id.reshape((1, 1))],
          dim=1
      ),
      "attention_mask": torch.cat(
          [inputs["attention_mask"], torch.tensor([[1]])],
          dim=1
      ),
  }

  return next_inputs

O código a seguir gera sequências de texto sem usar KV-Cache, medindo o tempo de execução e exibindo os tokens gerados.

quantidade_tokens = 20
durations_s       = {}
clear_memory()
for model_name in models.keys():
  model, tokenizer        = models[model_name]
  next_inputs             = first_input(model_name)
  generated_tokens        = []
  durations_s[model_name] = []
  clear_memory()


  for _ in range(quantidade_tokens):
      t0            = time.time()
      next_token_id = generate_token(next_inputs, model)

      durations_s[model_name] += [time.time() - t0]

      next_inputs = {
          "input_ids": torch.cat(
              [next_inputs["input_ids"], next_token_id.reshape((1, 1))],
              dim=1),
          "attention_mask": torch.cat(
              [next_inputs["attention_mask"], torch.tensor([[1]])],
              dim=1),
      }

      next_token = tokenizer.decode(next_token_id)
      generated_tokens.append(next_token)
  print('='*30)
  print(f'Modelo        : {model_name}')
  print(f"Tempo total   : {sum(durations_s[model_name])} s")
  print(f"Tokens/s      : {quantidade_tokens/sum(durations_s[model_name])}")
  print(f'Tokens gerados: {generated_tokens}')
==============================
Modelo        : llama
Tempo total   : 5.062213659286499 s
Tokens/s      : 3.9508407479622125
Tokens gerados: [' known', ' as', ' "', 'The', ' Great', ' Fire', ' of', ' London', '"\n', 'The', ' Great', ' Fire', ' of', ' London', ',', ' also', ' known', ' as', ' the', ' Great']
==============================
Modelo        : qwen
Tempo total   : 2.531498432159424 s
Tokens/s      : 7.900459169133105
Tokens gerados: [':', ' The', ' story', ' of', ' the', ' first', ' person', ' to', ' be', ' awarded', ' the', ' Nobel', ' Prize', ' in', ' Literature', '.', ' The', ' Nobel', ' Prize', ' in']
==============================
Modelo        : smol
Tempo total   : 1.897510051727295 s
Tokens/s      : 10.540128618446099
Tokens gerados: ['inja', ' warrior', ' named', ' K', 'ait', 'o', '**', '\n', '\n', 'K', 'ait', 'o', ' was', ' a', ' legendary', ' n', 'inja', ' warrior', ' from', ' ancient']
==============================
Modelo        : gpt
Tempo total   : 0.6520833969116211 s
Tokens/s      : 30.670923527149185
Tokens gerados: ['a', '.', '\n', '\n', 'A', 'rain', 'a', ' is', ' a', ' native', ' of', ' the', ' island', ' of', ' B', 'orne', 'o', ',', ' where', ' she']

Comparando a Performance

Agora vamos comparar o desempenho das gerações sem cache e com KV-Cache.

fig, ax = plt.subplots(nrows=1, ncols=len(models), figsize=(20, 2), sharey=False)

for idx_model, model_name in enumerate(models.keys()):
  sns.lineplot(durations_s[model_name], ax=ax[idx_model], label='No Cache')
  sns.lineplot(durations_cached_s[model_name], ax=ax[idx_model], label='KV-Cache')
  ax[idx_model].set_title(model_name)
  ax[idx_model].set_xlabel('Token')
  ax[idx_model].set_ylabel('Tempo (s)')
  ax[idx_model].grid(True, linestyle=':')
  ax[idx_model].set_axisbelow(True)

fig, ax = plt.subplots(nrows=1, ncols=len(models), figsize=(15, 2), sharey=True)

for idx_model, model_name in enumerate(models.keys()):
  sns.lineplot(np.cumsum(durations_s[model_name]), ax=ax[idx_model], label='No Cache')
  sns.lineplot(np.cumsum(durations_cached_s[model_name]), ax=ax[idx_model], label='KV-Cache')
  ax[idx_model].set_title(model_name)
  ax[idx_model].set_xlabel('Token')
  ax[idx_model].set_ylabel('Tempo (s)')
  ax[idx_model].grid(True, linestyle=':')
  ax[idx_model].set_axisbelow(True)

Fine Tuning Eficiente de Modelos de Linguagem

Nesse notebook veremos exemplos e fundamentos patra o finetuning (ajuste fino) de modelos de linguagem para aumentar o desenpenho e adaptá-los à tarefas específicas.

Para isso, vamos utilizar um SLM, o Smol 360M, além da biblioteca PEFT do HuggingFace para fazer o o ajuste de maneira mais eficiente, economizando recursos computacionais.

O foco dessa aula será no treinamento de Adapters com a téninca LoRA: LoRA: Low-Rank Adaptation of Large Language Models

Instalando e importando bibliotecas:

!pip install datasets --quiet
!pip install peft --quiet
!pip install trl --quiet

Definindo funções úteis

def load_model_and_tokenizer(model_name):
    model = AutoModelForCausalLM.from_pretrained(model_name)

    tokenizer = AutoTokenizer.from_pretrained(model_name)
    tokenizer.pad_token = tokenizer.eos_token

    return model, tokenizer
Para colocar o LoRA monualmente nos modelos, sem a utilização de bibliotecas prontas. Faremos esse teste alterando diretemente o modelo do pytorch:

class LoRALinear(nn.Module):
    def __init__(self, orig_linear: nn.Linear, r: int = 8, alpha: float = 16):
        super().__init__()

        self.orig_linear = orig_linear
        self.r = r
        self.alpha = alpha
        # Criando os Adapters: A (out_features x r) and B (r x in_features), onde r será o rank intermediário
        self.lora_A = nn.Parameter(torch.zeros(orig_linear.out_features, r))
        self.lora_B = nn.Parameter(torch.zeros(r, orig_linear.in_features))
        # Inicializando: usando Kaiming-uniform para a matriz A e zeros para a matriz B. Dessa forma, no inicio não ocorre mudanças no modelo.
        #nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        #with normal
        nn.init.normal_(self.lora_A)
        nn.init.zeros_(self.lora_B)

    def forward(self, x):
        # Compute original output plus LoRA adjustment.
        # F.linear computes x @ weight.T + bias.
        lora_update = F.linear(x, self.lora_A @ self.lora_B)
        return self.orig_linear(x) + self.alpha * lora_update

def add_lora_to_model(model: nn.Module, r: int = 8, alpha: float = 16, target_module_names=None):

    if target_module_names is None: #Colocaremos adapters apenas nas camadas com esses nome.
        target_module_names = ['q_proj', 'k_proj', 'v_proj']

    for name, module in model.named_children():
        # Recursively process children modules.
        add_lora_to_model(module, r, alpha, target_module_names)

        # Troca apara a camada do LoRA quando o nome da camada é igual a algum dos target_module_names
        if isinstance(module, nn.Linear) and any(t in name for t in target_module_names):
            setattr(model, name, LoRALinear(module, r=r, alpha=alpha))

    return model

E uma função que prepara o modelo com o LoRA para treinamento. Depois de adicionar os adapters, congelamos todas as camadas com excessão das matriz do LoRA.

def prepare_model_for_lora(model):

    for param in model.parameters():
        param.requires_grad = False

    for module in model.modules():
        if isinstance(module, LoRALinear):
            module.lora_A.requires_grad = True
            module.lora_B.requires_grad = True

E uma função de treinamento com pytorch para ajudtar as novas metrizes criadas e inseridas no modelo.

Nesse caso ainda criamos uma função de collate_fn, que prepara os dados para considerar os prompts inteiros (inputs) mas apenas calcular a loss para as respostas esperadas do modelo. Isso auxilia na convergência do modelo um vez que ele aprende apenas a responder aquela requisição.

def train_lora_model(model, tokenizer, dataset, epochs=3, batch_size=1, learning_rate=5e-5, device='cuda', max_steps = 700):
    model.train()  # Modo de treinamento
    optimizer = torch.optim.AdamW(model.parameters(), lr=learning_rate)

    # Vamos usar só o treino
    train_dataset = dataset["train"]

    # TEsse marker vai auxiliar a identificar one terimna a pergunta (prompt) e começa a resposta esperada
    marker = "### Response:\n"
    marker_ids = tokenizer(marker, add_special_tokens=False)["input_ids"]

    # Modificando as entradas para treinar apenas a parte de resposta
    def collate_fn(batch):
        texts = [item["Text"] for item in batch]
        encodings = tokenizer(texts, return_tensors="pt", padding=True, truncation=True).to(device) #tokenização
        # Nossas labels a princípio são iguais as entradas
        encodings["labels"] = encodings["input_ids"].clone()

        # Maskaramento das respostas
        for i in range(encodings["input_ids"].size(0)):
            sequence = encodings["input_ids"][i].tolist()
            marker_len = len(marker_ids)
            start_idx = -1
            # Procura pelo marker na resposta
            for j in range(len(sequence) - marker_len + 1):
                if sequence[j:j+marker_len] == marker_ids:
                    start_idx = j + marker_len
                    break
            # Quando encontra, substitui a parte da pergunta por -100. Isso serve para a loss não ser computada nesses tokens.
            if start_idx > 0:
                encodings["labels"][i, :start_idx] = -100
        return encodings

    #Criando um dataloader do Pytorch para treinamento por batches
    dataloader = torch.utils.data.DataLoader(train_dataset, batch_size=batch_size, shuffle=True, collate_fn=collate_fn)

    losses = []
    #E passamos pelo dataset fazendo um treinamento padrão de modelo (apenas nas caamadas LoRA não congeladas)
    for epoch in range(epochs):
        for step, batch in enumerate(dataloader):
            optimizer.zero_grad()
            outputs = model(input_ids=batch["input_ids"], labels=batch["labels"])
            loss = outputs.loss
            loss.backward()
            optimizer.step()
            if step % 100 == 0:
                print(f"Epoch {epoch} Step {step} Loss {loss.item():.4f}")
            losses.append(loss.item())
            if step > max_steps:
                break

    return model, losses

Adicionalmente, uma função simples de inferência com os modelos que serão ajustados:

def model_inference(model, tokenizer, prompt, max_new_tokens=100):
    inputs = tokenizer(prompt, return_tensors="pt").to(device)
    with torch.no_grad():
        outputs = model.generate(
            **inputs,
            max_new_tokens=max_new_tokens,
            eos_token_id=tokenizer.eos_token_id,
            pad_token_id=tokenizer.eos_token_id,
            do_sample=False,
        )
    response = tokenizer.decode(outputs[0], skip_special_tokens=True)
    return response
E funções auxiliares de manipulação dos adapters:

def get_lora_adapters(model):
    lora_params = []
    for module in model.modules():
        if isinstance(module, LoRALinear):
            lora_params.append((module.lora_A, module.lora_B))
    return lora_params

def set_lora_adapters(model, lora_params):
    for module in model.modules():
        if isinstance(module, LoRALinear):
            module.lora_A, module.lora_B = lora_params.pop(0)
    return model

Ajuste para Geração de SQL apartir de texto

Utilizaremos a função a seguir para formatar todos os prompts no mesmo padrão que queremos para treinar o modelo nessa nova tarefa.

É importante deixar o marker igual ao definido anteriormente, para um treinamento apenas nas respostas:

def sql_format_func(example):
    example["Text"] = f"### SQL Prompt:\n{example['sql_prompt']}\n### Response:\n{example['sql']}"
    return example

dataset = load_dataset("gretelai/synthetic_text_to_sql")
dataset = dataset.map(lambda x: sql_format_func(x))

model_name = 'HuggingFaceTB/SmolLM-360M'

model, tokenizer = load_model_and_tokenizer(model_name) #carregamos o modelo
model.to('cuda')

model = add_lora_to_model(model, r=8, alpha=16) #criamos as camadas lora
prepare_model_for_lora(model) #congelamos as demais camadas

model = model.to(device)
E agora vamos treinar para geração de SQL apartir de requisições em linguagem natural:
model_ft, losses = train_lora_model(model, tokenizer, dataset, epochs = 1, max_steps = 700, batch_size=2, learning_rate=5e-4)

Asking to truncate to max_length but no maximum length is provided and the model has no predefined maximum length. Default to no truncation.
Epoch 0 Step 0 Loss 4.5262
Epoch 0 Step 100 Loss 0.9094
Epoch 0 Step 200 Loss 0.2497
Epoch 0 Step 300 Loss 0.9896
Epoch 0 Step 400 Loss 0.7483
Epoch 0 Step 500 Loss 0.8518
Epoch 0 Step 600 Loss 0.7848
Epoch 0 Step 700 Loss 0.8016
sns.lineplot(x=range(len(losses)), y=losses)
plt.xlabel("Step")
plt.ylabel("Loss")
plt.title("Training Loss")
plt.grid()
plt.show()

Resposta do modelo original ao requisitar um SQL:

model = AutoModelForCausalLM.from_pretrained(model_name)
model.to('cuda')
model_inference(model, tokenizer, "### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\n")

'### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\n\nThe maximum quantity of seafood sold in a single transaction is 1000.\n\n### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\n\nThe maximum quantity of seafood sold in a single transaction is 1000.\n\n### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\n\nThe maximum quantity of seafood sold in a single transaction is 1'

Resposta do modelo ajustado ao requistar um SQL:

model_inference(model_ft, tokenizer,  "### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\n")

'### SQL Prompt:\n\nWhat is the maximum quantity of seafood sold in a single transaction?### Response:\nSELECT MAX(Quantity) FROM SHOPPER_SHOPPER WHERE TransactionID = 1;'