File size: 5,099 Bytes
eca5751
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
"""LoRA - Low-Rank Adaptation cho efficient fine-tuning."""
from __future__ import annotations

import math
import torch
import torch.nn as nn
from typing import Dict, List, Optional, Set
from dataclasses import dataclass, field


@dataclass
class LoRAConfig:
    """Config cho LoRA."""
    rank: int = 8              # LoRA rank (r)
    alpha: int = 16            # LoRA scaling factor (α)
    dropout: float = 0.0       # LoRA dropout
    # v0.4 fix: thay "gate_proj"+"up_proj" → "gate_up_proj" vì v0.3 SwiGLU(parallel=True)
    # fuses gate+up thành 1 matmul. Nếu không có gate_up_proj, có thể truyền cả 3.
    target_modules: List[str] = field(default_factory=lambda: [
        "q_proj", "k_proj", "v_proj", "o_proj",  # attention
        "gate_up_proj", "down_proj",                  # FFN (MLP-parallel)
    ])
    bias: str = "none"         # "none", "all", "lora_only"
    modules_to_save: List[str] = field(default_factory=list)  # Full-finetune these
    fan_in_fan_out: bool = False

    @property
    def scaling(self) -> float:
        if self.rank <= 0:
            return 0.0
        return self.alpha / self.rank


class LoRALinear(nn.Module):
    """Linear layer với LoRA adaptation.
    
    Adds low-rank matrices A and B such that:
        output = original(x) + scaling * B(A(x))
    
    Only A and B are trainable; original weights are frozen.
    """
    
    def __init__(
        self,
        original: nn.Linear,
        rank: int = 8,
        alpha: int = 16,
        dropout: float = 0.0,
    ):
        super().__init__()
        self.original = original
        self.rank = rank
        self.alpha = alpha
        self.scaling = alpha / rank
        
        # Freeze original
        for param in self.original.parameters():
            param.requires_grad = False
        
        # LoRA matrices
        in_features = original.in_features
        out_features = original.out_features
        
        # A: in_features × rank (init with kaiming)
        self.lora_A = nn.Parameter(torch.zeros(rank, in_features))
        nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
        
        # B: rank × out_features (init with zeros)
        self.lora_B = nn.Parameter(torch.zeros(out_features, rank))
        
        self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
    
    def forward(self, x: torch.Tensor) -> torch.Tensor:
        # Original output
        original_out = self.original(x)
        # LoRA delta: x @ A^T @ B^T * scaling
        lora_out = self.dropout(x) @ self.lora_A.T @ self.lora_B.T * self.scaling
        return original_out + lora_out
    
    def merge(self) -> nn.Linear:
        """Merge LoRA weights into original (for inference)."""
        with torch.no_grad():
            delta = (self.lora_B @ self.lora_A) * self.scaling
            self.original.weight.data += delta
        return self.original
    
    def extra_repr(self) -> str:
        return f"rank={self.rank}, alpha={self.alpha}, scaling={self.scaling:.3f}"


def apply_lora(
    model: nn.Module,
    config: LoRAConfig,
) -> nn.Module:
    """Apply LoRA to a model.
    
    Replaces target Linear modules with LoRALinear.
    Returns the modified model.
    
    Usage:
        config = LoRAConfig(rank=8, target_modules=["q_proj", "v_proj"])
        model = apply_lora(model, config)
        # Now only LoRA params are trainable
    """
    target_modules = set(config.target_modules)
    
    def _replace_recursive(module: nn.Module, prefix: str = ""):
        for name, child in list(module.named_children()):
            full_name = f"{prefix}.{name}" if prefix else name
            # Check if this module should be LoRA-adapted
            short_name = name
            if short_name in target_modules and isinstance(child, nn.Linear):
                lora_layer = LoRALinear(
                    original=child,
                    rank=config.rank,
                    alpha=config.alpha,
                    dropout=config.dropout,
                )
                setattr(module, name, lora_layer)
            else:
                _replace_recursive(child, full_name)
    
    _replace_recursive(model)
    
    # Make sure non-LoRA params are frozen
    for name, param in model.named_parameters():
        if "lora_" not in name and name not in config.modules_to_save:
            param.requires_grad = False
    
    return model


def get_lora_state_dict(model: nn.Module) -> Dict[str, torch.Tensor]:
    """Get only LoRA params (for saving)."""
    return {
        name: param
        for name, param in model.named_parameters()
        if "lora_" in name and param.requires_grad
    }


def count_lora_params(model: nn.Module) -> Dict[str, int]:
    """Count trainable vs total params."""
    total = sum(p.numel() for p in model.parameters())
    trainable = sum(p.numel() for p in model.parameters() if p.requires_grad)
    return {
        "total": total,
        "trainable": trainable,
        "frozen": total - trainable,
        "trainable_pct": trainable / total * 100 if total > 0 else 0,
    }