初始化项目,由ModelHub XC社区提供模型
Model: sabari2005/cyberslm-instruct Source: Original Platform
This commit is contained in:
35
.gitattributes
vendored
Normal file
35
.gitattributes
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.arrow filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.bin filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ftz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.gz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.h5 filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.joblib filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.model filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.npy filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.npz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.onnx filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.ot filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.parquet filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pickle filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pkl filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pt filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.pth filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.rar filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
||||||
|
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tar filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tflite filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.tgz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.wasm filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.xz filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*.zst filter=lfs diff=lfs merge=lfs -text
|
||||||
|
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
||||||
182
README.md
Normal file
182
README.md
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
---
|
||||||
|
license: apache-2.0
|
||||||
|
language:
|
||||||
|
- en
|
||||||
|
library_name: pytorch
|
||||||
|
pipeline_tag: text-generation
|
||||||
|
base_model: sabari2005/cyberslm-base
|
||||||
|
tags:
|
||||||
|
- cybersecurity
|
||||||
|
- security
|
||||||
|
- small-language-model
|
||||||
|
- instruction-tuned
|
||||||
|
- sft
|
||||||
|
- causal-lm
|
||||||
|
---
|
||||||
|
|
||||||
|
# CyberSLM-instruct — 33.5M-parameter cybersecurity assistant
|
||||||
|
|
||||||
|
Instruction-tuned from
|
||||||
|
[**sabari2005/cyberslm-base**](https://huggingface.co/sabari2005/cyberslm-base)
|
||||||
|
on 23,540 cybersecurity conversations.
|
||||||
|
|
||||||
|
**Code:** [github.com/Sabari2005/cyberslm](https://github.com/Sabari2005/cyberslm)
|
||||||
|
|
||||||
|
## Read this before using it
|
||||||
|
|
||||||
|
At 33.5M parameters this model learned the **shape** of a good answer — markdown
|
||||||
|
structure, numbered steps, worked examples, mitigation sections — and is
|
||||||
|
**frequently wrong about the content**.
|
||||||
|
|
||||||
|
Measured over 8 greedy prompts:
|
||||||
|
|
||||||
|
| category | mean 8-gram repetition | stopped on EOS |
|
||||||
|
|---|---:|---:|
|
||||||
|
| security | 18.7% | 1 / 4 |
|
||||||
|
| general | 18.9% | 1 / 2 |
|
||||||
|
| code | 19.9% | 0 / 2 |
|
||||||
|
| **overall** | **19.0%** | **2 / 8** |
|
||||||
|
|
||||||
|
**Do not use this as a factual security reference. Do not run code it
|
||||||
|
generates.** It is a demonstration of a correct end-to-end training pipeline and
|
||||||
|
a baseline for scaling.
|
||||||
|
|
||||||
|
### Where it works
|
||||||
|
|
||||||
|
Well-covered in-domain questions come out correct and terminate cleanly:
|
||||||
|
|
||||||
|
> **What is SQL injection and how do I prevent it?**
|
||||||
|
>
|
||||||
|
> SQL injection (SQLi) is a security vulnerability that allows attackers to
|
||||||
|
> manipulate database queries by injecting malicious SQL code through input
|
||||||
|
> fields. It occurs when user-supplied data is improperly sanitized, allowing
|
||||||
|
> attackers to manipulate the database. For example, if an attacker submits a
|
||||||
|
> username like `admin' --` as the username, the query becomes:
|
||||||
|
> `SELECT * FROM users WHERE username = '[input]' AND password = '[input]'`.
|
||||||
|
|
||||||
|
> **What is a firewall?**
|
||||||
|
>
|
||||||
|
> A firewall is a network security device or software that monitors and controls
|
||||||
|
> incoming and outgoing network traffic based on predefined security rules. It
|
||||||
|
> acts as a barrier between trusted internal networks and untrusted external
|
||||||
|
> networks, such as the internet.
|
||||||
|
|
||||||
|
### Where it fails
|
||||||
|
|
||||||
|
All observed, reproducible, and the majority case:
|
||||||
|
|
||||||
|
* **Wrong content, confident tone.** Asked to contrast symmetric and asymmetric
|
||||||
|
encryption it answered about hashing and IKE, never addressing the question.
|
||||||
|
* **Circular definitions.** "A buffer overflow is a type of buffer overflow that
|
||||||
|
could lead to arbitrary code execution."
|
||||||
|
* **Topically-adjacent but wrong vocabulary.** Asked how to investigate a
|
||||||
|
phishing email it produced `SameSite` and `Strict` — real security terms,
|
||||||
|
wrong topic (they are cookie attributes).
|
||||||
|
* **Degenerate loops in code.** `port: The port to use` repeated to the token
|
||||||
|
limit.
|
||||||
|
* **Unreliable termination.** Only 2 of 8 prompts stopped on EOS; the rest ran
|
||||||
|
to the token limit.
|
||||||
|
|
||||||
|
These are consequences of scale, not of the training run — the loss curve is
|
||||||
|
healthy and the pipeline is machine-verified (35 architecture checks, 173 tests).
|
||||||
|
|
||||||
|
## Model details
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| parameters | 33,531,264 |
|
||||||
|
| architecture | 12 layers, d_model 384, 6 heads, SwiGLU 1024, RoPE, RMSNorm, tied head |
|
||||||
|
| context | 2048 |
|
||||||
|
| vocab | 32,000 (SentencePiece BPE) |
|
||||||
|
| base model | sabari2005/cyberslm-base |
|
||||||
|
| SFT data | 23,540 conversations, 15.1M supervised tokens |
|
||||||
|
| epochs | 3 (2,208 optimizer steps) |
|
||||||
|
| optimiser | AdamW, lr 2e-5, 3% warmup, cosine, bf16 |
|
||||||
|
| best val loss | 2.2627 (response tokens only) |
|
||||||
|
|
||||||
|
Loss is computed on assistant responses only; prompts are masked. 88% of tokens
|
||||||
|
in the SFT set are supervised.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install torch sentencepiece
|
||||||
|
git clone https://huggingface.co/sabari2005/cyberslm-instruct
|
||||||
|
cd cyberslm-instruct
|
||||||
|
python infer_chat.py --prompt "What is SQL injection and how do I prevent it?"
|
||||||
|
```
|
||||||
|
|
||||||
|
Interactive:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python infer_chat.py --interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Options:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python infer_chat.py \
|
||||||
|
--prompt "What is a buffer overflow?" \
|
||||||
|
--max-new-tokens 200 \
|
||||||
|
--temperature 0.0 # 0 = greedy, recommended for this model
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prompt format
|
||||||
|
|
||||||
|
The model was trained on this exact layout, with a real BOS token id prepended
|
||||||
|
and EOS terminating each response:
|
||||||
|
|
||||||
|
```
|
||||||
|
### User:
|
||||||
|
{question}
|
||||||
|
|
||||||
|
### Assistant:
|
||||||
|
{response}<eos>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Build prompts with the bundled formatter** (`infer_chat.py` does this).
|
||||||
|
Hand-assembling the string produces different token ids at every segment
|
||||||
|
boundary, because SentencePiece prepends a word-boundary marker per `encode()`
|
||||||
|
call — the model then sees something it was never trained on.
|
||||||
|
|
||||||
|
```python
|
||||||
|
import torch
|
||||||
|
from configs.sft_config import default_config
|
||||||
|
from data.prompt_formatter import PromptFormatter, Tokenizer
|
||||||
|
from model.cyberslm import CyberSLM
|
||||||
|
|
||||||
|
cfg = default_config()
|
||||||
|
cfg.tokenizer.model_path = "tokenizer/tokenizer.model"
|
||||||
|
cfg.model.max_seq_len = cfg.data.max_seq_len = 2048
|
||||||
|
|
||||||
|
tok = Tokenizer(cfg.tokenizer.model_path)
|
||||||
|
fmt = PromptFormatter(cfg=cfg, tokenizer=tok)
|
||||||
|
|
||||||
|
model = CyberSLM(cfg.model)
|
||||||
|
model.load_state_dict(torch.load("models/instruct.pt", map_location="cpu",
|
||||||
|
weights_only=False))
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
ids = fmt.format_for_inference({"messages": [{"role": "user",
|
||||||
|
"content": "What is XSS?"}]})
|
||||||
|
out = model.generate(torch.tensor([ids]), max_new_tokens=200,
|
||||||
|
temperature=0.0, eos_id=tok.eos_id)
|
||||||
|
print(tok.decode(out[0, len(ids):].tolist()))
|
||||||
|
```
|
||||||
|
|
||||||
|
Decoding uses a KV cache — roughly 50–70 tok/s on CPU.
|
||||||
|
|
||||||
|
## Intended use
|
||||||
|
|
||||||
|
Research into small language models; a scaling baseline; a demonstration of a
|
||||||
|
verified training pipeline. **Not** for security advice, incident response, code
|
||||||
|
generation, or anything where accuracy matters.
|
||||||
|
|
||||||
|
## Training data
|
||||||
|
|
||||||
|
Not published. Curated cybersecurity instruction data; not redistributed.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Apache-2.0 for the code and weights. Verify licensing for downstream use against
|
||||||
|
the sources the data was curated from.
|
||||||
32
config.json
Normal file
32
config.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"architectures": [
|
||||||
|
"LlamaForCausalLM"
|
||||||
|
],
|
||||||
|
"attention_bias": false,
|
||||||
|
"attention_dropout": 0.0,
|
||||||
|
"bos_token_id": 2,
|
||||||
|
"dtype": "float32",
|
||||||
|
"eos_token_id": 3,
|
||||||
|
"head_dim": 64,
|
||||||
|
"hidden_act": "silu",
|
||||||
|
"hidden_size": 384,
|
||||||
|
"initializer_range": 0.02,
|
||||||
|
"intermediate_size": 1024,
|
||||||
|
"max_position_embeddings": 2048,
|
||||||
|
"mlp_bias": false,
|
||||||
|
"model_type": "llama",
|
||||||
|
"num_attention_heads": 6,
|
||||||
|
"num_hidden_layers": 12,
|
||||||
|
"num_key_value_heads": 6,
|
||||||
|
"pad_token_id": 0,
|
||||||
|
"pretraining_tp": 1,
|
||||||
|
"rms_norm_eps": 1e-06,
|
||||||
|
"rope_parameters": {
|
||||||
|
"rope_theta": 10000.0,
|
||||||
|
"rope_type": "default"
|
||||||
|
},
|
||||||
|
"tie_word_embeddings": true,
|
||||||
|
"transformers_version": "5.15.1",
|
||||||
|
"use_cache": true,
|
||||||
|
"vocab_size": 32000
|
||||||
|
}
|
||||||
35
cyberslm/model/__init__.py
Normal file
35
cyberslm/model/__init__.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
"""
|
||||||
|
CyberSLM model package.
|
||||||
|
|
||||||
|
Public API (grows as phases are added):
|
||||||
|
Phase 1: CyberSLMConfig, default_config, RMSNorm, RotaryPositionEmbedding, apply_rope
|
||||||
|
Phase 2: MultiHeadSelfAttention, SwiGLUFeedForward
|
||||||
|
Phase 3: DecoderBlock, CyberSLM, build_model
|
||||||
|
Phase 4: (training engine lives in cyberslm.training)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from cyberslm.model.config import CyberSLMConfig, default_config
|
||||||
|
from cyberslm.model.norm import RMSNorm
|
||||||
|
from cyberslm.model.rope import RotaryPositionEmbedding, apply_rope
|
||||||
|
from cyberslm.model.attention import MultiHeadSelfAttention
|
||||||
|
from cyberslm.model.ffn import SwiGLUFeedForward
|
||||||
|
from cyberslm.model.block import DecoderBlock
|
||||||
|
from cyberslm.model.model import CyberSLM, build_model, count_parameters, model_summary
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# Phase 1
|
||||||
|
"CyberSLMConfig",
|
||||||
|
"default_config",
|
||||||
|
"RMSNorm",
|
||||||
|
"RotaryPositionEmbedding",
|
||||||
|
"apply_rope",
|
||||||
|
# Phase 2
|
||||||
|
"MultiHeadSelfAttention",
|
||||||
|
"SwiGLUFeedForward",
|
||||||
|
# Phase 3
|
||||||
|
"DecoderBlock",
|
||||||
|
"CyberSLM",
|
||||||
|
"build_model",
|
||||||
|
"count_parameters",
|
||||||
|
"model_summary",
|
||||||
|
]
|
||||||
302
cyberslm/model/attention.py
Normal file
302
cyberslm/model/attention.py
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
"""
|
||||||
|
Multi-Head Self Attention (MHSA)
|
||||||
|
=================================
|
||||||
|
Standard scaled dot-product multi-head self attention with:
|
||||||
|
- Rotary Position Embedding (RoPE) on queries and keys
|
||||||
|
- Causal (auto-regressive) masking
|
||||||
|
- No bias on projection layers
|
||||||
|
- Pre-norm placement handled by the enclosing DecoderBlock
|
||||||
|
|
||||||
|
Mathematical definition
|
||||||
|
-----------------------
|
||||||
|
Given input X ∈ ℝ^{B×T×d}:
|
||||||
|
|
||||||
|
Q = X Wq, K = X Wk, V = X Wv (projections, no bias)
|
||||||
|
|
||||||
|
Split into H heads, each of dimension d_h = d / H:
|
||||||
|
|
||||||
|
Qₕ, Kₕ = RoPE(Qₕ), RoPE(Kₕ) (apply rotary embeddings)
|
||||||
|
|
||||||
|
Scaled dot-product attention per head:
|
||||||
|
|
||||||
|
Aₕ = softmax( (Qₕ Kₕᵀ) / √d_h + mask ) Vₕ
|
||||||
|
|
||||||
|
where mask[i,j] = 0 if j ≤ i else −∞ (causal constraint).
|
||||||
|
|
||||||
|
Concatenate and project:
|
||||||
|
|
||||||
|
output = concat(A₁, ..., A_H) Wo
|
||||||
|
|
||||||
|
Complexity
|
||||||
|
----------
|
||||||
|
Time : O(T² · d) — quadratic in sequence length (standard attention)
|
||||||
|
Space: O(T² · H) — attention weight matrix per head
|
||||||
|
|
||||||
|
Numerical stability
|
||||||
|
-------------------
|
||||||
|
- Scaling by 1/√d_h keeps the pre-softmax logits in a well-conditioned
|
||||||
|
range, preventing vanishing gradients from very peaked softmax outputs.
|
||||||
|
- Softmax is computed by PyTorch's numerically stable implementation
|
||||||
|
(subtract max before exp).
|
||||||
|
- RoPE is applied in float32 (see rope.py).
|
||||||
|
- Causal mask adds −∞ (not a large negative number) so masked positions
|
||||||
|
become exactly 0 after softmax — no gradient leakage.
|
||||||
|
|
||||||
|
FlashAttention compatibility
|
||||||
|
-----------------------------
|
||||||
|
The forward pass is written in a way that is structurally compatible with
|
||||||
|
a future drop-in replacement by ``torch.nn.functional.scaled_dot_product_attention``
|
||||||
|
(PyTorch 2.0+) or the ``flash-attn`` library. To migrate:
|
||||||
|
1. Replace the manual QKᵀ/softmax/V block with:
|
||||||
|
F.scaled_dot_product_attention(q, k, v, attn_mask=None,
|
||||||
|
dropout_p=0.0, is_causal=True)
|
||||||
|
2. Remove the manual mask addition (is_causal=True handles it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
from cyberslm.model.config import CyberSLMConfig
|
||||||
|
from cyberslm.model.rope import RotaryPositionEmbedding, apply_rope
|
||||||
|
|
||||||
|
|
||||||
|
def _causal_bias(
|
||||||
|
q_len: int,
|
||||||
|
k_len: int,
|
||||||
|
past_len: int,
|
||||||
|
dtype: torch.dtype,
|
||||||
|
device: torch.device,
|
||||||
|
) -> Tensor:
|
||||||
|
"""
|
||||||
|
Additive causal mask of shape ``(q_len, k_len)`` for a query block that
|
||||||
|
starts at absolute position ``past_len``.
|
||||||
|
|
||||||
|
Query row ``i`` represents absolute position ``past_len + i`` and may attend
|
||||||
|
to key columns ``0 .. past_len + i`` inclusive; everything after is -inf.
|
||||||
|
With ``past_len == 0`` this reduces to the usual upper-triangular mask.
|
||||||
|
"""
|
||||||
|
q_pos = torch.arange(q_len, device=device).unsqueeze(1) + past_len # (q,1)
|
||||||
|
k_pos = torch.arange(k_len, device=device).unsqueeze(0) # (1,k)
|
||||||
|
return torch.where(
|
||||||
|
k_pos <= q_pos,
|
||||||
|
torch.zeros((), dtype=dtype, device=device),
|
||||||
|
torch.full((), float("-inf"), dtype=dtype, device=device),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MultiHeadSelfAttention(nn.Module):
|
||||||
|
"""
|
||||||
|
Multi-Head Self Attention with RoPE and causal masking.
|
||||||
|
|
||||||
|
This module owns the four projection matrices (Wq, Wk, Wv, Wo),
|
||||||
|
the RoPE cache, and the causal mask buffer.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig
|
||||||
|
Validated model configuration.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
q_proj : nn.Linear ``(hidden_dim, hidden_dim)``, no bias
|
||||||
|
k_proj : nn.Linear ``(hidden_dim, hidden_dim)``, no bias
|
||||||
|
v_proj : nn.Linear ``(hidden_dim, hidden_dim)``, no bias
|
||||||
|
o_proj : nn.Linear ``(hidden_dim, hidden_dim)``, no bias
|
||||||
|
rope : RotaryPositionEmbedding
|
||||||
|
|
||||||
|
Shape
|
||||||
|
-----
|
||||||
|
Input : ``(batch, seq_len, hidden_dim)``
|
||||||
|
Output : ``(batch, seq_len, hidden_dim)``
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: CyberSLMConfig,
|
||||||
|
rope: Optional[RotaryPositionEmbedding] = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.hidden_dim = config.hidden_dim
|
||||||
|
self.num_heads = config.num_heads
|
||||||
|
self.head_dim = config.head_dim
|
||||||
|
self.scale = 1.0 / math.sqrt(self.head_dim)
|
||||||
|
self.attn_dropout_p = config.attn_dropout
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Projection layers — no bias (modern practice, saves ~4×384 params) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
self.q_proj = nn.Linear(config.hidden_dim, config.hidden_dim, bias=False)
|
||||||
|
self.k_proj = nn.Linear(config.hidden_dim, config.hidden_dim, bias=False)
|
||||||
|
self.v_proj = nn.Linear(config.hidden_dim, config.hidden_dim, bias=False)
|
||||||
|
self.o_proj = nn.Linear(config.hidden_dim, config.hidden_dim, bias=False)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# RoPE cache #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# The cos/sin tables depend only on (head_dim, max_seq_len, base), so
|
||||||
|
# every layer's would be byte-identical. CyberSLM builds ONE and passes
|
||||||
|
# it in; previously each of the 12 layers constructed its own, costing
|
||||||
|
# ~12 MB of duplicated buffers. Falls back to building its own so the
|
||||||
|
# module stays usable standalone (tests, ablations).
|
||||||
|
self.rope = rope if rope is not None else RotaryPositionEmbedding(
|
||||||
|
head_dim=config.head_dim,
|
||||||
|
max_seq_len=config.max_seq_len,
|
||||||
|
base=config.rope_base,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Causality is enforced by scaled_dot_product_attention(is_causal=...)
|
||||||
|
# rather than a materialised (max_seq_len × max_seq_len) mask buffer,
|
||||||
|
# which previously cost ~67 MB per layer.
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
x: Tensor,
|
||||||
|
attention_mask: Optional[Tensor] = None,
|
||||||
|
return_attn_weights: bool = False,
|
||||||
|
kv_cache: Optional[Tuple[Tensor, Tensor]] = None,
|
||||||
|
use_cache: bool = False,
|
||||||
|
) -> Tuple[Tensor, Optional[Tensor], Optional[Tuple[Tensor, Tensor]]]:
|
||||||
|
"""
|
||||||
|
Compute multi-head self attention.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Input of shape ``(batch, seq_len, hidden_dim)``.
|
||||||
|
attention_mask : Optional[Tensor]
|
||||||
|
Key-padding mask of shape ``(batch, seq_len)`` with 1 for real
|
||||||
|
tokens and 0 for padding. When provided, padded keys are excluded
|
||||||
|
from every query's attention (in addition to the causal mask).
|
||||||
|
``None`` means no padding (the common training case with packed
|
||||||
|
sequences).
|
||||||
|
return_attn_weights : bool
|
||||||
|
If True, also return the attention weight matrix for inspection.
|
||||||
|
This forces the slower explicit-softmax path; leave False for
|
||||||
|
training so the fused kernel is used.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
output : Tensor
|
||||||
|
Shape ``(batch, seq_len, hidden_dim)``.
|
||||||
|
attn_weights : Optional[Tensor]
|
||||||
|
Shape ``(batch, num_heads, seq_len, seq_len)`` if
|
||||||
|
``return_attn_weights=True``, else ``None``.
|
||||||
|
present : Optional[Tuple[Tensor, Tensor]]
|
||||||
|
The concatenated ``(k, v)`` for this layer when ``use_cache=True``,
|
||||||
|
to be fed back on the next decoding step. ``None`` otherwise.
|
||||||
|
"""
|
||||||
|
B, T, _ = x.shape
|
||||||
|
# Number of tokens already in the cache == absolute position of x[0].
|
||||||
|
past_len = kv_cache[0].size(2) if kv_cache is not None else 0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 1. Linear projections #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
q = self.q_proj(x) # (B, T, hidden_dim)
|
||||||
|
k = self.k_proj(x)
|
||||||
|
v = self.v_proj(x)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 2. Reshape to (B, H, T, head_dim) for multi-head computation #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
q = q.view(B, T, self.num_heads, self.head_dim).transpose(1, 2) # (B,H,T,D)
|
||||||
|
k = k.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
|
||||||
|
v = v.view(B, T, self.num_heads, self.head_dim).transpose(1, 2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3. Apply Rotary Position Embeddings to Q and K #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# offset=past_len so a cached decode step rotates the new token by its
|
||||||
|
# TRUE absolute position rather than position 0.
|
||||||
|
q, k = apply_rope(q, k, self.rope, offset=past_len)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3b. Prepend the cache. RoPE is applied to the new k BEFORE the
|
||||||
|
# concat, and cached keys were already rotated when they were first
|
||||||
|
# computed -- so each key keeps the rotation for its own position.
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
if kv_cache is not None:
|
||||||
|
k = torch.cat([kv_cache[0], k], dim=2)
|
||||||
|
v = torch.cat([kv_cache[1], v], dim=2)
|
||||||
|
present = (k, v) if use_cache else None
|
||||||
|
S = k.size(2) # total key length (past + current)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 4. Build the additive key-padding bias (if any). #
|
||||||
|
# Shape broadcasts over heads and query positions: (B, 1, 1, T). #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
pad_bias: Optional[Tensor] = None
|
||||||
|
if attention_mask is not None:
|
||||||
|
# 0 where padding → -inf added to those key columns.
|
||||||
|
pad = (attention_mask == 0)[:, None, None, :] # (B,1,1,S) bool
|
||||||
|
pad_bias = torch.zeros(
|
||||||
|
(B, 1, 1, pad.size(-1)), dtype=q.dtype, device=q.device
|
||||||
|
).masked_fill(pad, float("-inf"))
|
||||||
|
|
||||||
|
if not return_attn_weights:
|
||||||
|
# Fused, memory-efficient path (FlashAttention when available).
|
||||||
|
# is_causal=True applies the causal mask without materialising it.
|
||||||
|
if pad_bias is None and past_len == 0:
|
||||||
|
context = F.scaled_dot_product_attention(
|
||||||
|
q, k, v,
|
||||||
|
is_causal=True,
|
||||||
|
dropout_p=self.attn_dropout_p if self.training else 0.0,
|
||||||
|
)
|
||||||
|
elif pad_bias is None and T == 1:
|
||||||
|
# Single-token decode: every cached key is in the past, so the
|
||||||
|
# causal constraint is already satisfied and no mask is needed.
|
||||||
|
context = F.scaled_dot_product_attention(
|
||||||
|
q, k, v, dropout_p=0.0,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Combine causal + padding into one additive float mask.
|
||||||
|
# Query i sits at absolute position past_len + i and may attend
|
||||||
|
# to keys 0..past_len+i, so the triangle is offset by past_len.
|
||||||
|
causal = _causal_bias(T, S, past_len, q.dtype, q.device)
|
||||||
|
attn_bias = causal[None, None, :, :]
|
||||||
|
if pad_bias is not None:
|
||||||
|
attn_bias = attn_bias + pad_bias # (B,1,T,S)
|
||||||
|
context = F.scaled_dot_product_attention(
|
||||||
|
q, k, v,
|
||||||
|
attn_mask=attn_bias,
|
||||||
|
dropout_p=self.attn_dropout_p if self.training else 0.0,
|
||||||
|
)
|
||||||
|
attn_weights = None
|
||||||
|
else:
|
||||||
|
# Explicit path — needed only when the caller wants the weights.
|
||||||
|
scores = torch.matmul(q, k.transpose(-2, -1)) * self.scale # (B,H,T,S)
|
||||||
|
causal = _causal_bias(T, S, past_len, scores.dtype, scores.device)
|
||||||
|
scores = scores + causal[None, None, :, :]
|
||||||
|
if pad_bias is not None:
|
||||||
|
scores = scores + pad_bias
|
||||||
|
attn_weights = F.softmax(scores, dim=-1, dtype=torch.float32)
|
||||||
|
if self.attn_dropout_p > 0.0 and self.training:
|
||||||
|
attn_weights = F.dropout(attn_weights, p=self.attn_dropout_p)
|
||||||
|
attn_weights = attn_weights.to(v.dtype)
|
||||||
|
context = torch.matmul(attn_weights, v) # (B,H,T,head_dim)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 8. Merge heads: (B, H, T, D) → (B, T, H*D) = (B, T, hidden_dim) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
context = context.transpose(1, 2).contiguous().view(B, T, self.hidden_dim)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 9. Output projection #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
output = self.o_proj(context)
|
||||||
|
|
||||||
|
return output, attn_weights, present
|
||||||
|
|
||||||
|
def extra_repr(self) -> str:
|
||||||
|
return (
|
||||||
|
f"hidden_dim={self.hidden_dim}, "
|
||||||
|
f"num_heads={self.num_heads}, "
|
||||||
|
f"head_dim={self.head_dim}"
|
||||||
|
)
|
||||||
144
cyberslm/model/block.py
Normal file
144
cyberslm/model/block.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Transformer Decoder Block
|
||||||
|
==========================
|
||||||
|
A single Pre-Norm residual decoder block consisting of:
|
||||||
|
1. RMSNorm → Multi-Head Self Attention → residual add
|
||||||
|
2. RMSNorm → SwiGLU FFN → residual add
|
||||||
|
|
||||||
|
Pre-Norm architecture
|
||||||
|
---------------------
|
||||||
|
Post-Norm (original Transformer):
|
||||||
|
x = LayerNorm(x + SubLayer(x))
|
||||||
|
|
||||||
|
Pre-Norm (modern: GPT-2 onward, LLaMA, etc.):
|
||||||
|
x = x + SubLayer(LayerNorm(x))
|
||||||
|
|
||||||
|
Pre-Norm is strongly preferred for deep networks because:
|
||||||
|
- Gradients flow through the residual connection bypassing the
|
||||||
|
normalisation, preventing vanishing gradients in very deep stacks.
|
||||||
|
- Training is more stable without learning-rate warmup tricks.
|
||||||
|
- Final RMSNorm on the output is added at the model level (not here)
|
||||||
|
to normalise the final residual stream before the LM head.
|
||||||
|
|
||||||
|
Residual stream
|
||||||
|
---------------
|
||||||
|
The residual stream x ∈ ℝ^{B×T×d} is the backbone of the model. Each
|
||||||
|
sub-layer reads from it, computes a delta, and adds back:
|
||||||
|
|
||||||
|
Δ_attn = MHSA( RMSNorm(x) )
|
||||||
|
x = x + Δ_attn
|
||||||
|
|
||||||
|
Δ_ffn = FFN( RMSNorm(x) )
|
||||||
|
x = x + Δ_ffn
|
||||||
|
|
||||||
|
This additive structure means the gradient of the loss with respect to
|
||||||
|
early layers contains a direct path through the identity (residual),
|
||||||
|
enabling reliable training of 12+ layer models.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
from cyberslm.model.config import CyberSLMConfig
|
||||||
|
from cyberslm.model.norm import RMSNorm
|
||||||
|
from cyberslm.model.attention import MultiHeadSelfAttention
|
||||||
|
from cyberslm.model.ffn import SwiGLUFeedForward
|
||||||
|
|
||||||
|
|
||||||
|
class DecoderBlock(nn.Module):
|
||||||
|
"""
|
||||||
|
Pre-Norm Transformer Decoder Block.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig
|
||||||
|
Validated model configuration.
|
||||||
|
layer_idx : int
|
||||||
|
Zero-based index of this block in the stack (used for display only).
|
||||||
|
|
||||||
|
Sub-modules
|
||||||
|
-----------
|
||||||
|
attn_norm : RMSNorm
|
||||||
|
Normalises the residual stream before attention.
|
||||||
|
attn : MultiHeadSelfAttention
|
||||||
|
Self attention with RoPE and causal masking.
|
||||||
|
ffn_norm : RMSNorm
|
||||||
|
Normalises the residual stream before the FFN.
|
||||||
|
ffn : SwiGLUFeedForward
|
||||||
|
SwiGLU position-wise feed-forward network.
|
||||||
|
|
||||||
|
Shape
|
||||||
|
-----
|
||||||
|
Input : ``(batch, seq_len, hidden_dim)``
|
||||||
|
Output : ``(batch, seq_len, hidden_dim)``
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
config: CyberSLMConfig,
|
||||||
|
layer_idx: int = 0,
|
||||||
|
rope=None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.layer_idx = layer_idx
|
||||||
|
|
||||||
|
# Pre-norm before attention.
|
||||||
|
self.attn_norm = RMSNorm(config.hidden_dim, eps=config.norm_eps)
|
||||||
|
# Multi-head self attention (owns RoPE + causal mask buffers).
|
||||||
|
self.attn = MultiHeadSelfAttention(config, rope=rope)
|
||||||
|
|
||||||
|
# Pre-norm before FFN.
|
||||||
|
self.ffn_norm = RMSNorm(config.hidden_dim, eps=config.norm_eps)
|
||||||
|
# SwiGLU feed-forward.
|
||||||
|
self.ffn = SwiGLUFeedForward(config)
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
x: Tensor,
|
||||||
|
attention_mask: Optional[Tensor] = None,
|
||||||
|
return_attn_weights: bool = False,
|
||||||
|
kv_cache: Optional[Tuple[Tensor, Tensor]] = None,
|
||||||
|
use_cache: bool = False,
|
||||||
|
) -> Tuple[Tensor, Optional[Tensor], Optional[Tuple[Tensor, Tensor]]]:
|
||||||
|
"""
|
||||||
|
Apply one Pre-Norm residual decoder block.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Residual stream of shape ``(batch, seq_len, hidden_dim)``.
|
||||||
|
attention_mask : Optional[Tensor]
|
||||||
|
Key-padding mask ``(batch, seq_len)`` (1=keep, 0=pad), forwarded
|
||||||
|
to the attention sub-layer. ``None`` for packed/unpadded batches.
|
||||||
|
return_attn_weights : bool
|
||||||
|
Propagated to the attention sub-layer.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
x : Tensor
|
||||||
|
Updated residual stream, same shape as input.
|
||||||
|
attn_weights : Optional[Tensor]
|
||||||
|
Attention weights if requested, else None.
|
||||||
|
"""
|
||||||
|
# ---- Attention sub-layer ----------------------------------------- #
|
||||||
|
attn_out, attn_weights, present = self.attn(
|
||||||
|
self.attn_norm(x),
|
||||||
|
attention_mask=attention_mask,
|
||||||
|
return_attn_weights=return_attn_weights,
|
||||||
|
kv_cache=kv_cache,
|
||||||
|
use_cache=use_cache,
|
||||||
|
)
|
||||||
|
x = x + attn_out
|
||||||
|
|
||||||
|
# ---- FFN sub-layer ------------------------------------------------ #
|
||||||
|
x = x + self.ffn(self.ffn_norm(x))
|
||||||
|
|
||||||
|
return x, attn_weights, present
|
||||||
|
|
||||||
|
def extra_repr(self) -> str:
|
||||||
|
return f"layer_idx={self.layer_idx}"
|
||||||
234
cyberslm/model/config.py
Normal file
234
cyberslm/model/config.py
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
"""
|
||||||
|
CyberSLM Model Configuration
|
||||||
|
=============================
|
||||||
|
Defines the complete, validated configuration for the CyberSLM decoder-only
|
||||||
|
transformer. All hyperparameters are frozen at construction time and validated
|
||||||
|
for mathematical consistency before any model component is instantiated.
|
||||||
|
|
||||||
|
Architecture summary
|
||||||
|
--------------------
|
||||||
|
- Hidden dim : 384
|
||||||
|
- Decoder layers : 12
|
||||||
|
- Attention heads : 6
|
||||||
|
- Head dim : 64 (hidden_dim / num_heads = 384 / 6 = 64)
|
||||||
|
- FFN inner dim : 1024 (SwiGLU gate + value = 2 × 1024 → projects back to 384)
|
||||||
|
- Vocab size : 32 000
|
||||||
|
- Max context : 4 096
|
||||||
|
- Approx params : 33.53 M
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CyberSLMConfig:
|
||||||
|
"""
|
||||||
|
Immutable configuration for the CyberSLM decoder-only transformer.
|
||||||
|
|
||||||
|
All fields are set once at construction; the frozen dataclass guarantees
|
||||||
|
no accidental mutation during training. Call :meth:`validate` immediately
|
||||||
|
after construction or use the convenience constructor
|
||||||
|
:func:`default_config`.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
vocab_size : int
|
||||||
|
Number of tokens in the SentencePiece BPE vocabulary.
|
||||||
|
max_seq_len : int
|
||||||
|
Maximum token sequence length (context window).
|
||||||
|
hidden_dim : int
|
||||||
|
Embedding dimension ``d_model``.
|
||||||
|
num_layers : int
|
||||||
|
Number of stacked transformer decoder blocks.
|
||||||
|
num_heads : int
|
||||||
|
Number of attention heads. Must evenly divide ``hidden_dim``.
|
||||||
|
head_dim : int
|
||||||
|
Dimension of each attention head. Must equal ``hidden_dim // num_heads``.
|
||||||
|
ffn_hidden_dim : int
|
||||||
|
Inner dimension of the SwiGLU feed-forward network.
|
||||||
|
The gate and value projections each map hidden_dim → ffn_hidden_dim,
|
||||||
|
and the output projection maps ffn_hidden_dim → hidden_dim.
|
||||||
|
rope_base : int
|
||||||
|
Base for Rotary Position Embedding frequency computation (θ = 10 000).
|
||||||
|
norm_eps : float
|
||||||
|
Epsilon added inside RMSNorm to prevent division by zero.
|
||||||
|
tie_weights : bool
|
||||||
|
When True the output projection shares weights with the token embedding.
|
||||||
|
bias : bool
|
||||||
|
When True linear layers include a bias term (False = modern practice).
|
||||||
|
dropout : float
|
||||||
|
Residual / feed-forward dropout probability (0.0 = disabled).
|
||||||
|
attn_dropout : float
|
||||||
|
Attention weight dropout probability (0.0 = disabled).
|
||||||
|
pad_token_id : Optional[int]
|
||||||
|
Token ID used for padding; None if the dataset never pads.
|
||||||
|
bos_token_id : Optional[int]
|
||||||
|
Beginning-of-sequence token ID.
|
||||||
|
eos_token_id : Optional[int]
|
||||||
|
End-of-sequence token ID.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Vocabulary & sequence #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
vocab_size: int = 32_000
|
||||||
|
max_seq_len: int = 4_096
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Transformer dimensions #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
hidden_dim: int = 384
|
||||||
|
num_layers: int = 12
|
||||||
|
num_heads: int = 6
|
||||||
|
head_dim: int = 64 # must equal hidden_dim // num_heads
|
||||||
|
ffn_hidden_dim: int = 1_024
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Positional encoding #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
rope_base: int = 10_000
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Normalization #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
norm_eps: float = 1e-6
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Architecture flags #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
tie_weights: bool = True
|
||||||
|
bias: bool = False
|
||||||
|
dropout: float = 0.0
|
||||||
|
attn_dropout: float = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Special token IDs (set by tokenizer integration layer) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
pad_token_id: Optional[int] = None
|
||||||
|
bos_token_id: Optional[int] = 2 # real SentencePiece BOS id
|
||||||
|
eos_token_id: Optional[int] = 3 # real SentencePiece EOS id
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Validation #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def validate(self) -> "CyberSLMConfig":
|
||||||
|
"""
|
||||||
|
Assert mathematical consistency of every hyperparameter.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If any hyperparameter violates an architectural constraint.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
CyberSLMConfig
|
||||||
|
Self, to allow chaining: ``cfg = CyberSLMConfig().validate()``.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Positivity checks
|
||||||
|
for name, value in [
|
||||||
|
("vocab_size", self.vocab_size),
|
||||||
|
("max_seq_len", self.max_seq_len),
|
||||||
|
("hidden_dim", self.hidden_dim),
|
||||||
|
("num_layers", self.num_layers),
|
||||||
|
("num_heads", self.num_heads),
|
||||||
|
("head_dim", self.head_dim),
|
||||||
|
("ffn_hidden_dim", self.ffn_hidden_dim),
|
||||||
|
("rope_base", self.rope_base),
|
||||||
|
]:
|
||||||
|
if value <= 0:
|
||||||
|
errors.append(f"{name} must be positive, got {value}")
|
||||||
|
|
||||||
|
# Attention head consistency
|
||||||
|
if self.hidden_dim % self.num_heads != 0:
|
||||||
|
errors.append(
|
||||||
|
f"hidden_dim ({self.hidden_dim}) must be divisible by "
|
||||||
|
f"num_heads ({self.num_heads})"
|
||||||
|
)
|
||||||
|
expected_head_dim = self.hidden_dim // self.num_heads
|
||||||
|
if self.head_dim != expected_head_dim:
|
||||||
|
errors.append(
|
||||||
|
f"head_dim ({self.head_dim}) must equal "
|
||||||
|
f"hidden_dim // num_heads = {expected_head_dim}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# RoPE requires even head_dim (pairs of sin/cos)
|
||||||
|
if self.head_dim % 2 != 0:
|
||||||
|
errors.append(
|
||||||
|
f"head_dim ({self.head_dim}) must be even for RoPE"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Dropout bounds
|
||||||
|
for name, value in [("dropout", self.dropout), ("attn_dropout", self.attn_dropout)]:
|
||||||
|
if not (0.0 <= value < 1.0):
|
||||||
|
errors.append(f"{name} must be in [0, 1), got {value}")
|
||||||
|
|
||||||
|
# norm_eps positivity
|
||||||
|
if self.norm_eps <= 0.0:
|
||||||
|
errors.append(f"norm_eps must be positive, got {self.norm_eps}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
raise ValueError(
|
||||||
|
"CyberSLMConfig validation failed:\n"
|
||||||
|
+ "\n".join(f" • {e}" for e in errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Derived properties #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
@property
|
||||||
|
def total_attention_dim(self) -> int:
|
||||||
|
"""``num_heads × head_dim`` — equals ``hidden_dim`` by construction."""
|
||||||
|
return self.num_heads * self.head_dim
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rope_half_dim(self) -> int:
|
||||||
|
"""Number of frequency pairs in RoPE (``head_dim // 2``)."""
|
||||||
|
return self.head_dim // 2
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Display #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
def __str__(self) -> str:
|
||||||
|
lines = [
|
||||||
|
"CyberSLMConfig",
|
||||||
|
"=" * 40,
|
||||||
|
f" vocab_size : {self.vocab_size:,}",
|
||||||
|
f" max_seq_len : {self.max_seq_len:,}",
|
||||||
|
f" hidden_dim : {self.hidden_dim}",
|
||||||
|
f" num_layers : {self.num_layers}",
|
||||||
|
f" num_heads : {self.num_heads}",
|
||||||
|
f" head_dim : {self.head_dim}",
|
||||||
|
f" ffn_hidden_dim : {self.ffn_hidden_dim}",
|
||||||
|
f" rope_base : {self.rope_base}",
|
||||||
|
f" norm_eps : {self.norm_eps}",
|
||||||
|
f" tie_weights : {self.tie_weights}",
|
||||||
|
f" bias : {self.bias}",
|
||||||
|
f" dropout : {self.dropout}",
|
||||||
|
f" attn_dropout : {self.attn_dropout}",
|
||||||
|
]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config() -> CyberSLMConfig:
|
||||||
|
"""
|
||||||
|
Return the validated default CyberSLM configuration.
|
||||||
|
|
||||||
|
This is the single source of truth for all training runs.
|
||||||
|
All hyperparameters match the finalized architecture specification.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
CyberSLMConfig
|
||||||
|
A validated, immutable configuration object.
|
||||||
|
"""
|
||||||
|
cfg = CyberSLMConfig()
|
||||||
|
cfg.validate()
|
||||||
|
return cfg
|
||||||
127
cyberslm/model/ffn.py
Normal file
127
cyberslm/model/ffn.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
"""
|
||||||
|
SwiGLU Feed-Forward Network (FFN)
|
||||||
|
===================================
|
||||||
|
Reference: "GLU Variants Improve Transformer" (Noam Shazeer, 2020)
|
||||||
|
https://arxiv.org/abs/2002.05202
|
||||||
|
|
||||||
|
Mathematical definition
|
||||||
|
-----------------------
|
||||||
|
Standard FFN (for contrast):
|
||||||
|
FFN(x) = activation(x W₁) W₂
|
||||||
|
|
||||||
|
SwiGLU FFN:
|
||||||
|
SwiGLU(x) = (x W_gate ⊙ swish(x W_gate)) W₂ ← WRONG shorthand
|
||||||
|
|
||||||
|
Correct form with separate gate and value projections:
|
||||||
|
gate(x) = x W_gate ∈ ℝ^{B×T×ffn_dim}
|
||||||
|
val(x) = x W_val ∈ ℝ^{B×T×ffn_dim}
|
||||||
|
hidden = swish(gate(x)) ⊙ val(x)
|
||||||
|
out = hidden W_out ∈ ℝ^{B×T×hidden_dim}
|
||||||
|
|
||||||
|
where swish(z) = z · sigmoid(z) = z · σ(z).
|
||||||
|
|
||||||
|
Why SwiGLU
|
||||||
|
----------
|
||||||
|
- The gating mechanism (⊙) gives the network a multiplicative path to
|
||||||
|
control information flow — intuitively "how much" of each feature passes
|
||||||
|
through at each position.
|
||||||
|
- swish is smooth and non-monotonic, empirically outperforming ReLU and
|
||||||
|
GELU in large-scale experiments (PaLM, LLaMA, etc.).
|
||||||
|
- The factored W_gate / W_val structure adds one extra matrix but improves
|
||||||
|
quality relative to a standard 2-layer FFN of the same parameter budget.
|
||||||
|
|
||||||
|
Parameter count
|
||||||
|
---------------
|
||||||
|
Three matrices: W_gate, W_val, W_out
|
||||||
|
W_gate : hidden_dim × ffn_hidden_dim (384 × 1024 = 393 216)
|
||||||
|
W_val : hidden_dim × ffn_hidden_dim (384 × 1024 = 393 216)
|
||||||
|
W_out : ffn_hidden_dim × hidden_dim (1024 × 384 = 393 216)
|
||||||
|
Total per layer: 1 179 648 ≈ 1.18 M
|
||||||
|
|
||||||
|
No bias on any projection (consistent with modern practice).
|
||||||
|
|
||||||
|
Numerical stability
|
||||||
|
-------------------
|
||||||
|
- swish(z) = z · σ(z) is numerically safe for all real z.
|
||||||
|
- σ(z) = 1/(1+exp(−z)) in PyTorch uses a numerically stable implementation.
|
||||||
|
- The element-wise product of two bounded (after sigmoid) quantities does
|
||||||
|
not amplify values explosively.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
import torch.nn.functional as F
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
from cyberslm.model.config import CyberSLMConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SwiGLUFeedForward(nn.Module):
|
||||||
|
"""
|
||||||
|
SwiGLU Feed-Forward Network.
|
||||||
|
|
||||||
|
Consists of three bias-free linear projections:
|
||||||
|
``gate_proj``, ``val_proj``, and ``out_proj``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig
|
||||||
|
Validated model configuration.
|
||||||
|
|
||||||
|
Shape
|
||||||
|
-----
|
||||||
|
Input : ``(batch, seq_len, hidden_dim)``
|
||||||
|
Output : ``(batch, seq_len, hidden_dim)``
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: CyberSLMConfig) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.hidden_dim = config.hidden_dim
|
||||||
|
self.ffn_hidden_dim = config.ffn_hidden_dim
|
||||||
|
|
||||||
|
# Gate projection: produces the gating signal fed through swish.
|
||||||
|
self.gate_proj = nn.Linear(
|
||||||
|
config.hidden_dim, config.ffn_hidden_dim, bias=False
|
||||||
|
)
|
||||||
|
# Value projection: produces the value signal gated element-wise.
|
||||||
|
self.val_proj = nn.Linear(
|
||||||
|
config.hidden_dim, config.ffn_hidden_dim, bias=False
|
||||||
|
)
|
||||||
|
# Output projection: maps back to residual stream dimension.
|
||||||
|
self.out_proj = nn.Linear(
|
||||||
|
config.ffn_hidden_dim, config.hidden_dim, bias=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
|
"""
|
||||||
|
Apply SwiGLU feed-forward transformation.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Input of shape ``(batch, seq_len, hidden_dim)``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Output of shape ``(batch, seq_len, hidden_dim)``.
|
||||||
|
"""
|
||||||
|
# gate: (B, T, ffn_hidden_dim)
|
||||||
|
# val: (B, T, ffn_hidden_dim)
|
||||||
|
gate = self.gate_proj(x)
|
||||||
|
val = self.val_proj(x)
|
||||||
|
|
||||||
|
# SwiGLU: swish(gate) ⊙ val
|
||||||
|
# F.silu is swish: silu(z) = z * sigmoid(z)
|
||||||
|
hidden = F.silu(gate) * val # (B, T, ffn_hidden_dim)
|
||||||
|
|
||||||
|
# Project back to hidden_dim
|
||||||
|
return self.out_proj(hidden) # (B, T, hidden_dim)
|
||||||
|
|
||||||
|
def extra_repr(self) -> str:
|
||||||
|
return (
|
||||||
|
f"hidden_dim={self.hidden_dim}, "
|
||||||
|
f"ffn_hidden_dim={self.ffn_hidden_dim}"
|
||||||
|
)
|
||||||
514
cyberslm/model/model.py
Normal file
514
cyberslm/model/model.py
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
"""
|
||||||
|
CyberSLM — Complete Decoder-Only Language Model
|
||||||
|
================================================
|
||||||
|
Assembles all components into the full model:
|
||||||
|
|
||||||
|
Token Embedding → Decoder Stack (×12) → Final RMSNorm → LM Head
|
||||||
|
|
||||||
|
Weight tying
|
||||||
|
------------
|
||||||
|
The LM head (output projection that maps hidden_dim → vocab_size) shares its
|
||||||
|
weight matrix with the token embedding (vocab_size × hidden_dim).
|
||||||
|
|
||||||
|
Mathematical justification: both the embedding matrix E and the unembedding
|
||||||
|
matrix U operate in the same semantic space. Tying U = Eᵀ forces consistency
|
||||||
|
("a token's output representation should be similar to its input representation"),
|
||||||
|
reduces parameters by vocab_size × hidden_dim = 32 000 × 384 ≈ 12.3 M, and
|
||||||
|
empirically improves perplexity on small models.
|
||||||
|
|
||||||
|
Parameter count breakdown
|
||||||
|
--------------------------
|
||||||
|
Component Params
|
||||||
|
---------------------------------------- ------
|
||||||
|
Token embedding (vocab × hidden) 12 288 000
|
||||||
|
↳ shared with LM head (no extra cost) 0
|
||||||
|
Decoder blocks × 12:
|
||||||
|
attn_norm (RMSNorm) 384 ×12 = 4 608
|
||||||
|
attn Q/K/V/O proj 589 824 ×12 = 7 077 888
|
||||||
|
ffn_norm (RMSNorm) 384 ×12 = 4 608
|
||||||
|
ffn gate/val/out 1 179 648 ×12 = 14 155 776
|
||||||
|
Final RMSNorm 384
|
||||||
|
---------------------------------------- ------
|
||||||
|
Total ≈ 33 531 264 (≈33.53 M)
|
||||||
|
|
||||||
|
Note: RoPE buffers and causal mask buffers are NOT parameters.
|
||||||
|
|
||||||
|
Initialisation
|
||||||
|
--------------
|
||||||
|
- Embeddings: N(0, 0.02) — small but non-zero, standard practice.
|
||||||
|
- All linear weights: N(0, 0.02)
|
||||||
|
- All RMSNorm γ: 1.0 (already set by RMSNorm.__init__)
|
||||||
|
- Output projection weights = Embedding weights (weight tying).
|
||||||
|
- Scaled output projections: attention o_proj and FFN out_proj are
|
||||||
|
scaled by 1/√(2·num_layers) to prevent residual stream variance
|
||||||
|
from growing with depth (following GPT-2 / LLaMA init practice).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
from cyberslm.model.config import CyberSLMConfig, default_config
|
||||||
|
from cyberslm.model.block import DecoderBlock
|
||||||
|
from cyberslm.model.norm import RMSNorm
|
||||||
|
from cyberslm.model.rope import RotaryPositionEmbedding
|
||||||
|
|
||||||
|
|
||||||
|
class CyberSLM(nn.Module):
|
||||||
|
"""
|
||||||
|
CyberSLM Decoder-Only Transformer.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig
|
||||||
|
Validated model configuration.
|
||||||
|
|
||||||
|
Attributes
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig
|
||||||
|
embedding : nn.Embedding
|
||||||
|
Token embedding table, shape ``(vocab_size, hidden_dim)``.
|
||||||
|
layers : nn.ModuleList[DecoderBlock]
|
||||||
|
Stack of ``num_layers`` decoder blocks.
|
||||||
|
final_norm : RMSNorm
|
||||||
|
Applied to the residual stream after the last block.
|
||||||
|
lm_head : nn.Linear
|
||||||
|
Projects hidden_dim → vocab_size. Weight tied to ``embedding``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: CyberSLMConfig) -> None:
|
||||||
|
super().__init__()
|
||||||
|
config.validate()
|
||||||
|
self.config = config
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Token embedding #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
self.embedding = nn.Embedding(config.vocab_size, config.hidden_dim)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Decoder stack #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# One RoPE table shared by every layer (identical by construction).
|
||||||
|
self.rope = RotaryPositionEmbedding(
|
||||||
|
head_dim=config.head_dim,
|
||||||
|
max_seq_len=config.max_seq_len,
|
||||||
|
base=config.rope_base,
|
||||||
|
)
|
||||||
|
self.layers = nn.ModuleList(
|
||||||
|
[
|
||||||
|
DecoderBlock(config, layer_idx=i, rope=self.rope)
|
||||||
|
for i in range(config.num_layers)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Final normalisation #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
self.final_norm = RMSNorm(config.hidden_dim, eps=config.norm_eps)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Language model head #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# bias=False: unembedding never needs a bias term.
|
||||||
|
self.lm_head = nn.Linear(config.hidden_dim, config.vocab_size, bias=False)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Weight tying: lm_head.weight ≡ embedding.weight #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
if config.tie_weights:
|
||||||
|
self.lm_head.weight = self.embedding.weight
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Parameter initialisation #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
self._init_weights()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Initialisation #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _init_weights(self) -> None:
|
||||||
|
"""
|
||||||
|
Initialise all parameters with production-quality values.
|
||||||
|
|
||||||
|
Strategy
|
||||||
|
--------
|
||||||
|
- Embedding : N(0, 0.02)
|
||||||
|
- All Linear weights : N(0, 0.02)
|
||||||
|
- o_proj and out_proj: scaled down by 1/√(2·L) where L = num_layers
|
||||||
|
to stabilise the residual stream variance at initialisation.
|
||||||
|
- All RMSNorm γ : 1.0 (already set in RMSNorm.__init__)
|
||||||
|
- All biases : 0.0 (none exist in this config)
|
||||||
|
"""
|
||||||
|
std = 0.02
|
||||||
|
scaled_std = std / math.sqrt(2.0 * self.config.num_layers)
|
||||||
|
|
||||||
|
for name, module in self.named_modules():
|
||||||
|
if isinstance(module, nn.Embedding):
|
||||||
|
nn.init.normal_(module.weight, mean=0.0, std=std)
|
||||||
|
elif isinstance(module, nn.Linear):
|
||||||
|
# Scaled init for residual output projections.
|
||||||
|
if name.endswith("o_proj") or name.endswith("out_proj"):
|
||||||
|
nn.init.normal_(module.weight, mean=0.0, std=scaled_std)
|
||||||
|
else:
|
||||||
|
nn.init.normal_(module.weight, mean=0.0, std=std)
|
||||||
|
if module.bias is not None:
|
||||||
|
nn.init.zeros_(module.bias)
|
||||||
|
|
||||||
|
# Weight tying must be re-applied after init because _init_weights
|
||||||
|
# initialised embedding.weight; lm_head.weight already points to the
|
||||||
|
# same tensor (Python object reference), so no extra step needed.
|
||||||
|
# Verify it is still tied.
|
||||||
|
if self.config.tie_weights:
|
||||||
|
assert self.lm_head.weight is self.embedding.weight, (
|
||||||
|
"Weight tying broken after _init_weights"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Forward pass #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def forward(
|
||||||
|
self,
|
||||||
|
input_ids: Tensor,
|
||||||
|
attention_mask: Optional[Tensor] = None,
|
||||||
|
return_all_attn_weights: bool = False,
|
||||||
|
) -> Tuple[Tensor, List[Optional[Tensor]]]:
|
||||||
|
"""
|
||||||
|
Run the full forward pass.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_ids : Tensor
|
||||||
|
Long tensor of shape ``(batch, seq_len)`` with token IDs in
|
||||||
|
``[0, vocab_size)``.
|
||||||
|
attention_mask : Optional[Tensor]
|
||||||
|
Optional key-padding mask ``(batch, seq_len)`` (1=keep, 0=pad).
|
||||||
|
Pass this when batches contain right-padded sequences so padded
|
||||||
|
positions do not corrupt real tokens; leave ``None`` for packed,
|
||||||
|
unpadded training batches.
|
||||||
|
return_all_attn_weights : bool
|
||||||
|
If True, collect and return attention weights from every layer.
|
||||||
|
Disabled by default for training efficiency.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
logits : Tensor
|
||||||
|
Shape ``(batch, seq_len, vocab_size)``. Raw (pre-softmax) scores.
|
||||||
|
all_attn_weights : List[Optional[Tensor]]
|
||||||
|
One entry per decoder block; each is either the attention weight
|
||||||
|
tensor or ``None``.
|
||||||
|
|
||||||
|
Notes
|
||||||
|
-----
|
||||||
|
For language model training the standard loss is:
|
||||||
|
loss = cross_entropy(logits[:, :-1].reshape(-1, V),
|
||||||
|
input_ids[:, 1:].reshape(-1))
|
||||||
|
where we predict the next token at every position.
|
||||||
|
"""
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 1. Token embedding #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
x = self.embedding(input_ids) # (B, T, hidden_dim)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 2. Decoder stack #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
all_attn_weights: List[Optional[Tensor]] = []
|
||||||
|
for block in self.layers:
|
||||||
|
# use_cache=False -> `present` is None; forward() deliberately keeps
|
||||||
|
# its (logits, attn_weights) return signature unchanged. Cached
|
||||||
|
# decoding lives in generate() instead of overloading this method.
|
||||||
|
x, attn_w, _ = block(
|
||||||
|
x,
|
||||||
|
attention_mask=attention_mask,
|
||||||
|
return_attn_weights=return_all_attn_weights,
|
||||||
|
)
|
||||||
|
all_attn_weights.append(attn_w)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 3. Final normalisation #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
x = self.final_norm(x) # (B, T, hidden_dim)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# 4. LM head (weight-tied unembedding) #
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
logits = self.lm_head(x) # (B, T, vocab_size)
|
||||||
|
|
||||||
|
return logits, all_attn_weights
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Cached autoregressive generation #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@torch.no_grad()
|
||||||
|
def generate(
|
||||||
|
self,
|
||||||
|
input_ids: Tensor,
|
||||||
|
max_new_tokens: int = 256,
|
||||||
|
temperature: float = 0.0,
|
||||||
|
top_k: int = 0,
|
||||||
|
top_p: float = 1.0,
|
||||||
|
repetition_penalty: float = 1.0,
|
||||||
|
eos_id: Optional[int] = None,
|
||||||
|
) -> Tensor:
|
||||||
|
"""
|
||||||
|
Generate continuations using a KV cache.
|
||||||
|
|
||||||
|
Why this exists
|
||||||
|
---------------
|
||||||
|
The previous generation loop re-ran the whole 12-layer stack over the
|
||||||
|
entire prefix for every single token, making decoding O(n^2) in
|
||||||
|
sequence length. With a cache each step attends over the cached keys and
|
||||||
|
only computes the new token, which is O(n) overall.
|
||||||
|
|
||||||
|
Sampling is applied per row, so batched prompts are supported. Rows that
|
||||||
|
have emitted ``eos_id`` are frozen (further tokens are forced to
|
||||||
|
``eos_id``) and generation stops once every row is finished.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_ids : Tensor ``(batch, prompt_len)`` of token ids.
|
||||||
|
temperature : 0.0 selects greedy argmax; >0 samples.
|
||||||
|
top_k / top_p : 0 and 1.0 respectively disable the filter.
|
||||||
|
repetition_penalty : >1.0 divides logits of already-present tokens.
|
||||||
|
eos_id : stop token; ``None`` means never stop early.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor ``(batch, prompt_len + generated)`` including the prompt.
|
||||||
|
"""
|
||||||
|
self.eval()
|
||||||
|
device = input_ids.device
|
||||||
|
B = input_ids.size(0)
|
||||||
|
max_ctx = self.config.max_seq_len
|
||||||
|
|
||||||
|
if input_ids.size(1) >= max_ctx:
|
||||||
|
input_ids = input_ids[:, -(max_ctx - 1):]
|
||||||
|
|
||||||
|
caches: List[Optional[tuple]] = [None] * len(self.layers)
|
||||||
|
finished = torch.zeros(B, dtype=torch.bool, device=device)
|
||||||
|
out = input_ids
|
||||||
|
cur = input_ids
|
||||||
|
|
||||||
|
for _ in range(max_new_tokens):
|
||||||
|
if out.size(1) >= max_ctx:
|
||||||
|
break
|
||||||
|
|
||||||
|
h = self.embedding(cur)
|
||||||
|
new_caches = []
|
||||||
|
for block, layer_cache in zip(self.layers, caches):
|
||||||
|
h, _, present = block(h, kv_cache=layer_cache, use_cache=True)
|
||||||
|
new_caches.append(present)
|
||||||
|
caches = new_caches
|
||||||
|
logits = self.lm_head(self.final_norm(h))[:, -1, :].float()
|
||||||
|
|
||||||
|
if repetition_penalty != 1.0:
|
||||||
|
for b in range(B):
|
||||||
|
seen = torch.unique(out[b])
|
||||||
|
lg = logits[b, seen]
|
||||||
|
# Divide positives, multiply negatives, so the penalty always
|
||||||
|
# pushes a token DOWN regardless of its logit's sign.
|
||||||
|
logits[b, seen] = torch.where(
|
||||||
|
lg > 0, lg / repetition_penalty, lg * repetition_penalty
|
||||||
|
)
|
||||||
|
|
||||||
|
if temperature == 0.0:
|
||||||
|
nxt = logits.argmax(dim=-1)
|
||||||
|
else:
|
||||||
|
logits = logits / temperature
|
||||||
|
if top_k > 0:
|
||||||
|
k = min(top_k, logits.size(-1))
|
||||||
|
thresh = torch.topk(logits, k, dim=-1).values[:, -1, None]
|
||||||
|
logits = logits.masked_fill(logits < thresh, float("-inf"))
|
||||||
|
if top_p < 1.0:
|
||||||
|
srt, idx = torch.sort(logits, descending=True, dim=-1)
|
||||||
|
probs = torch.softmax(srt, dim=-1)
|
||||||
|
cum = probs.cumsum(dim=-1) - probs # prob mass strictly before this token
|
||||||
|
srt = srt.masked_fill(cum > top_p, float("-inf"))
|
||||||
|
logits = torch.full_like(logits, float("-inf")).scatter(1, idx, srt)
|
||||||
|
nxt = torch.multinomial(torch.softmax(logits, dim=-1), 1).squeeze(-1)
|
||||||
|
|
||||||
|
if eos_id is not None:
|
||||||
|
nxt = torch.where(finished, torch.full_like(nxt, eos_id), nxt)
|
||||||
|
finished = finished | (nxt == eos_id)
|
||||||
|
|
||||||
|
cur = nxt.unsqueeze(1)
|
||||||
|
out = torch.cat([out, cur], dim=1)
|
||||||
|
|
||||||
|
if eos_id is not None and bool(finished.all()):
|
||||||
|
break
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
# Convenience: next-token logits #
|
||||||
|
# ---------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def get_next_token_logits(self, input_ids: Tensor) -> Tensor:
|
||||||
|
"""
|
||||||
|
Return logits for the next token after the last input position.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
input_ids : Tensor
|
||||||
|
Shape ``(batch, seq_len)``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Shape ``(batch, vocab_size)``.
|
||||||
|
"""
|
||||||
|
logits, _ = self.forward(input_ids)
|
||||||
|
return logits[:, -1, :] # (B, vocab_size)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parameter counting #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def count_parameters(model: nn.Module) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Count trainable and total parameters.
|
||||||
|
|
||||||
|
Because of weight tying, lm_head.weight is counted only once
|
||||||
|
(it shares storage with embedding.weight).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model : nn.Module
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
dict with keys:
|
||||||
|
``total`` — total parameter elements (no double-counting)
|
||||||
|
``trainable`` — trainable parameter elements
|
||||||
|
"""
|
||||||
|
seen: set = set()
|
||||||
|
total = 0
|
||||||
|
trainable = 0
|
||||||
|
for param in model.parameters():
|
||||||
|
# data_ptr() is unique per underlying storage tensor.
|
||||||
|
if param.data_ptr() in seen:
|
||||||
|
continue
|
||||||
|
seen.add(param.data_ptr())
|
||||||
|
n = param.numel()
|
||||||
|
total += n
|
||||||
|
if param.requires_grad:
|
||||||
|
trainable += n
|
||||||
|
return {"total": total, "trainable": trainable}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Model summary #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def model_summary(model: CyberSLM) -> str:
|
||||||
|
"""
|
||||||
|
Return a human-readable model summary string.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
model : CyberSLM
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
str
|
||||||
|
Formatted multi-line summary including per-component parameter counts.
|
||||||
|
"""
|
||||||
|
cfg = model.config
|
||||||
|
param_info = count_parameters(model)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"=" * 60,
|
||||||
|
f" CyberSLM Model Summary",
|
||||||
|
"=" * 60,
|
||||||
|
f" Architecture : Decoder-only Transformer",
|
||||||
|
f" Hidden dim : {cfg.hidden_dim}",
|
||||||
|
f" Num layers : {cfg.num_layers}",
|
||||||
|
f" Num heads : {cfg.num_heads}",
|
||||||
|
f" Head dim : {cfg.head_dim}",
|
||||||
|
f" FFN hidden dim : {cfg.ffn_hidden_dim}",
|
||||||
|
f" Vocab size : {cfg.vocab_size:,}",
|
||||||
|
f" Max seq len : {cfg.max_seq_len:,}",
|
||||||
|
f" Weight tied : {cfg.tie_weights}",
|
||||||
|
f" RoPE base : {cfg.rope_base}",
|
||||||
|
"-" * 60,
|
||||||
|
f" Total params : {param_info['total']:>14,}",
|
||||||
|
f" Trainable : {param_info['trainable']:>14,}",
|
||||||
|
"-" * 60,
|
||||||
|
" Per-component:",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Embedding
|
||||||
|
emb_p = model.embedding.weight.numel()
|
||||||
|
lines.append(f" Embedding : {emb_p:>12,}")
|
||||||
|
|
||||||
|
# Per-block breakdown (just the first block, all identical)
|
||||||
|
block = model.layers[0]
|
||||||
|
attn_norm_p = sum(p.numel() for p in block.attn_norm.parameters())
|
||||||
|
attn_p = sum(p.numel() for p in block.attn.parameters())
|
||||||
|
ffn_norm_p = sum(p.numel() for p in block.ffn_norm.parameters())
|
||||||
|
ffn_p = sum(p.numel() for p in block.ffn.parameters())
|
||||||
|
block_total = attn_norm_p + attn_p + ffn_norm_p + ffn_p
|
||||||
|
lines.append(f" Decoder block (×{cfg.num_layers:2d}) : {block_total:>12,} per block")
|
||||||
|
lines.append(f" attn_norm : {attn_norm_p:>12,}")
|
||||||
|
lines.append(f" attention (Q/K/V/O) : {attn_p:>12,}")
|
||||||
|
lines.append(f" ffn_norm : {ffn_norm_p:>12,}")
|
||||||
|
lines.append(f" ffn (gate/val/out) : {ffn_p:>12,}")
|
||||||
|
lines.append(f" Decoder stack total : {block_total * cfg.num_layers:>12,}")
|
||||||
|
|
||||||
|
# Final norm
|
||||||
|
final_norm_p = sum(p.numel() for p in model.final_norm.parameters())
|
||||||
|
lines.append(f" Final RMSNorm : {final_norm_p:>12,}")
|
||||||
|
|
||||||
|
# LM head — note: weight tied, so 0 additional params
|
||||||
|
lm_tied_note = " (weight-tied, no extra params)" if cfg.tie_weights else ""
|
||||||
|
lines.append(f" LM head : {0:>12,}{lm_tied_note}")
|
||||||
|
|
||||||
|
lines.append("=" * 60)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Model builder #
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def build_model(
|
||||||
|
config: Optional[CyberSLMConfig] = None,
|
||||||
|
device: Optional[torch.device] = None,
|
||||||
|
) -> CyberSLM:
|
||||||
|
"""
|
||||||
|
Build, initialise, and optionally place the CyberSLM model.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
config : CyberSLMConfig, optional
|
||||||
|
Validated config. Uses :func:`default_config` if None.
|
||||||
|
device : torch.device, optional
|
||||||
|
Target device. Stays on CPU if None.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
CyberSLM
|
||||||
|
Fully initialised model ready for training.
|
||||||
|
"""
|
||||||
|
if config is None:
|
||||||
|
config = default_config()
|
||||||
|
else:
|
||||||
|
config.validate()
|
||||||
|
|
||||||
|
model = CyberSLM(config)
|
||||||
|
|
||||||
|
if device is not None:
|
||||||
|
model = model.to(device)
|
||||||
|
|
||||||
|
return model
|
||||||
114
cyberslm/model/norm.py
Normal file
114
cyberslm/model/norm.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""
|
||||||
|
RMSNorm — Root Mean Square Layer Normalisation
|
||||||
|
===============================================
|
||||||
|
Reference: "Root Mean Square Layer Normalization" (Zhang & Sennrich, 2019)
|
||||||
|
https://arxiv.org/abs/1910.07467
|
||||||
|
|
||||||
|
Mathematical definition
|
||||||
|
-----------------------
|
||||||
|
Given an input vector **x** ∈ ℝ^d:
|
||||||
|
|
||||||
|
RMS(x) = sqrt( (1/d) * Σ xᵢ² + ε )
|
||||||
|
|
||||||
|
RMSNorm(x) = (x / RMS(x)) * γ
|
||||||
|
|
||||||
|
where γ ∈ ℝ^d is a learned per-channel scale (initialised to 1.0) and
|
||||||
|
ε > 0 is a small constant for numerical stability.
|
||||||
|
|
||||||
|
Key differences from LayerNorm
|
||||||
|
-------------------------------
|
||||||
|
- No mean subtraction (no re-centering step).
|
||||||
|
- No learned bias β (the bias-free variant).
|
||||||
|
- ~30 % fewer operations than LayerNorm, which matters across 12 layers.
|
||||||
|
- Empirically matches or exceeds LayerNorm in modern transformer training.
|
||||||
|
|
||||||
|
Numerical stability
|
||||||
|
-------------------
|
||||||
|
- The RMS is computed in float32 regardless of input dtype. This prevents
|
||||||
|
underflow/overflow when activations are in bfloat16 or float16. For our
|
||||||
|
FP32 training runs this cast is a no-op but it is correct and future-proof.
|
||||||
|
- ε = 1e-6 (default) prevents division by zero even for near-zero inputs.
|
||||||
|
- The scale γ is cast back to the input dtype before multiplication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
|
||||||
|
class RMSNorm(nn.Module):
|
||||||
|
"""
|
||||||
|
Root Mean Square Layer Normalisation without mean-centering or bias.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
dim : int
|
||||||
|
Feature dimension to normalise over (last dimension of the input).
|
||||||
|
eps : float
|
||||||
|
Small constant added to the RMS denominator for numerical stability.
|
||||||
|
Defaults to 1e-6.
|
||||||
|
|
||||||
|
Shape
|
||||||
|
-----
|
||||||
|
Input : ``(*, dim)`` — any leading batch / sequence dimensions.
|
||||||
|
Output : ``(*, dim)`` — same shape as input.
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
>>> norm = RMSNorm(384, eps=1e-6)
|
||||||
|
>>> x = torch.randn(2, 512, 384)
|
||||||
|
>>> y = norm(x)
|
||||||
|
>>> y.shape
|
||||||
|
torch.Size([2, 512, 384])
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, dim: int, eps: float = 1e-6) -> None:
|
||||||
|
super().__init__()
|
||||||
|
if dim <= 0:
|
||||||
|
raise ValueError(f"dim must be positive, got {dim}")
|
||||||
|
if eps <= 0.0:
|
||||||
|
raise ValueError(f"eps must be positive, got {eps}")
|
||||||
|
|
||||||
|
self.dim = dim
|
||||||
|
self.eps = eps
|
||||||
|
# Learned per-channel scale, initialised to 1 (identity transform).
|
||||||
|
self.weight = nn.Parameter(torch.ones(dim))
|
||||||
|
|
||||||
|
def _compute_rms(self, x: Tensor) -> Tensor:
|
||||||
|
"""
|
||||||
|
Compute the RMS over the last dimension.
|
||||||
|
|
||||||
|
Always promotes to float32 to prevent numerical issues with
|
||||||
|
reduced-precision dtypes. For FP32 training this is a no-op.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Shape ``(*, 1)`` — one RMS value per token position.
|
||||||
|
"""
|
||||||
|
return x.float().pow(2).mean(dim=-1, keepdim=True).add(self.eps).sqrt()
|
||||||
|
|
||||||
|
def forward(self, x: Tensor) -> Tensor:
|
||||||
|
"""
|
||||||
|
Normalise ``x`` by its per-token root mean square.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Input of shape ``(*, dim)``.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Normalised output of the same shape and dtype as ``x``.
|
||||||
|
"""
|
||||||
|
rms = self._compute_rms(x)
|
||||||
|
# Normalise in float32, then cast back to original dtype.
|
||||||
|
x_normed = x.float() / rms
|
||||||
|
# Scale by learned weights (cast to input dtype for type safety).
|
||||||
|
return (x_normed * self.weight.float()).to(x.dtype)
|
||||||
|
|
||||||
|
def extra_repr(self) -> str:
|
||||||
|
return f"dim={self.dim}, eps={self.eps}"
|
||||||
291
cyberslm/model/rope.py
Normal file
291
cyberslm/model/rope.py
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
"""
|
||||||
|
Rotary Position Embedding (RoPE)
|
||||||
|
=================================
|
||||||
|
Reference: "RoFormer: Enhanced Transformer with Rotary Position Embedding"
|
||||||
|
(Su et al., 2021) — https://arxiv.org/abs/2104.09864
|
||||||
|
|
||||||
|
Mathematical definition
|
||||||
|
-----------------------
|
||||||
|
For a query (or key) vector **q** at position ``m`` with head dimension ``d``:
|
||||||
|
|
||||||
|
1. Partition **q** into pairs: (q₁, q₂), (q₃, q₄), ..., (q_{d-1}, q_d).
|
||||||
|
|
||||||
|
2. For each pair index ``i ∈ {0, 1, ..., d/2 - 1}`` define the frequency:
|
||||||
|
|
||||||
|
θᵢ = 1 / base^(2i / d) (base = 10 000)
|
||||||
|
|
||||||
|
3. Apply a 2-D rotation to each pair at position ``m``:
|
||||||
|
|
||||||
|
R(m, θᵢ) · (q_{2i}, q_{2i+1}) =
|
||||||
|
(q_{2i} cos(m·θᵢ) − q_{2i+1} sin(m·θᵢ),
|
||||||
|
q_{2i} sin(m·θᵢ) + q_{2i+1} cos(m·θᵢ))
|
||||||
|
|
||||||
|
This is equivalent to multiplying **q** (viewed as complex numbers) by
|
||||||
|
``exp(i · m · θ)``, which preserves the inner product of relative positions:
|
||||||
|
|
||||||
|
⟨R(m)q, R(n)k⟩ depends only on (m − n),
|
||||||
|
|
||||||
|
giving translation-equivariant attention without absolute position tokens.
|
||||||
|
|
||||||
|
Efficient implementation
|
||||||
|
------------------------
|
||||||
|
The rotation can be expressed without complex arithmetic:
|
||||||
|
|
||||||
|
q_rot = [q_even · cos − q_odd · sin,
|
||||||
|
q_even · sin + q_odd · cos]
|
||||||
|
|
||||||
|
where ``q_even = q[..., 0::2]``, ``q_odd = q[..., 1::2]``.
|
||||||
|
|
||||||
|
Numerically interleaved form (even/odd) vs. split-half form (first/second half)
|
||||||
|
are equivalent; we use the interleaved form for clarity.
|
||||||
|
|
||||||
|
Precomputation
|
||||||
|
--------------
|
||||||
|
``cos`` and ``sin`` tensors of shape ``(max_seq_len, head_dim // 2)`` are
|
||||||
|
computed once and registered as non-parameter buffers so they move with the
|
||||||
|
module (CPU ↔ GPU) and are not included in ``state_dict`` checkpoints.
|
||||||
|
|
||||||
|
Stability notes
|
||||||
|
---------------
|
||||||
|
- Frequencies are computed in float64 then cast to float32 to minimise
|
||||||
|
floating-point error in ``pow`` and ``arange``.
|
||||||
|
- All rotations are executed in float32 to prevent loss of precision.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
import torch
|
||||||
|
import torch.nn as nn
|
||||||
|
from torch import Tensor
|
||||||
|
|
||||||
|
|
||||||
|
class RotaryPositionEmbedding(nn.Module):
|
||||||
|
"""
|
||||||
|
Precomputed Rotary Position Embedding cache.
|
||||||
|
|
||||||
|
Registers ``cos`` and ``sin`` buffers of shape
|
||||||
|
``(max_seq_len, head_dim // 2)`` at construction time. Applying RoPE
|
||||||
|
to a query or key tensor costs only element-wise multiplications and
|
||||||
|
additions — no matrix multiplications.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
head_dim : int
|
||||||
|
Dimension of each attention head. Must be even.
|
||||||
|
max_seq_len : int
|
||||||
|
Maximum sequence length to pre-compute. Sequences longer than this
|
||||||
|
will raise an error at runtime.
|
||||||
|
base : int
|
||||||
|
RoPE base frequency (10 000 in the original paper).
|
||||||
|
|
||||||
|
Shape of ``apply``
|
||||||
|
------------------
|
||||||
|
Input : ``(batch, num_heads, seq_len, head_dim)``
|
||||||
|
Output : ``(batch, num_heads, seq_len, head_dim)``
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
head_dim: int,
|
||||||
|
max_seq_len: int,
|
||||||
|
base: int = 10_000,
|
||||||
|
) -> None:
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
if head_dim <= 0 or head_dim % 2 != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"head_dim must be a positive even integer, got {head_dim}"
|
||||||
|
)
|
||||||
|
if max_seq_len <= 0:
|
||||||
|
raise ValueError(f"max_seq_len must be positive, got {max_seq_len}")
|
||||||
|
if base <= 0:
|
||||||
|
raise ValueError(f"base must be positive, got {base}")
|
||||||
|
|
||||||
|
self.head_dim = head_dim
|
||||||
|
self.max_seq_len = max_seq_len
|
||||||
|
self.base = base
|
||||||
|
|
||||||
|
# Pre-compute and register buffers (not model parameters).
|
||||||
|
cos_cache, sin_cache = self._build_cache(head_dim, max_seq_len, base)
|
||||||
|
self.register_buffer("cos_cache", cos_cache, persistent=False)
|
||||||
|
self.register_buffer("sin_cache", sin_cache, persistent=False)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_cache(
|
||||||
|
head_dim: int,
|
||||||
|
max_seq_len: int,
|
||||||
|
base: int,
|
||||||
|
) -> Tuple[Tensor, Tensor]:
|
||||||
|
"""
|
||||||
|
Build ``(cos, sin)`` caches of shape ``(max_seq_len, head_dim // 2)``.
|
||||||
|
|
||||||
|
Computation is performed in float64 for precision, then cast to
|
||||||
|
float32 for storage.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[Tensor, Tensor]
|
||||||
|
``cos_cache`` and ``sin_cache``, each of shape
|
||||||
|
``(max_seq_len, head_dim // 2)``.
|
||||||
|
"""
|
||||||
|
half_dim = head_dim // 2
|
||||||
|
|
||||||
|
# θᵢ = 1 / base^(2i / head_dim) for i ∈ {0, ..., half_dim - 1}
|
||||||
|
# Computed in float64 to avoid precision loss in the exponent.
|
||||||
|
inv_freq = 1.0 / (
|
||||||
|
base ** (torch.arange(0, head_dim, 2, dtype=torch.float64) / head_dim)
|
||||||
|
)
|
||||||
|
# Shape: (half_dim,)
|
||||||
|
|
||||||
|
# Position indices m ∈ {0, 1, ..., max_seq_len - 1}
|
||||||
|
positions = torch.arange(max_seq_len, dtype=torch.float64)
|
||||||
|
# Shape: (max_seq_len,)
|
||||||
|
|
||||||
|
# Outer product: angles[m, i] = m * θᵢ
|
||||||
|
angles = torch.outer(positions, inv_freq)
|
||||||
|
# Shape: (max_seq_len, half_dim)
|
||||||
|
|
||||||
|
cos_cache = angles.cos().to(torch.float32)
|
||||||
|
sin_cache = angles.sin().to(torch.float32)
|
||||||
|
|
||||||
|
return cos_cache, sin_cache
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rotate_half(x: Tensor) -> Tensor:
|
||||||
|
"""
|
||||||
|
Rotate the last dimension by interleaving even/odd pairs.
|
||||||
|
|
||||||
|
For input ``x`` of shape ``(..., head_dim)``:
|
||||||
|
|
||||||
|
x_even = x[..., 0::2] (positions 0, 2, 4, ...)
|
||||||
|
x_odd = x[..., 1::2] (positions 1, 3, 5, ...)
|
||||||
|
|
||||||
|
Returns ``[-x_odd, x_even]`` interleaved back into ``(..., head_dim)``.
|
||||||
|
This is the standard rotation that implements the complex-number trick.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Shape ``(..., head_dim)`` where ``head_dim`` is even.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Same shape as ``x``.
|
||||||
|
"""
|
||||||
|
x_even = x[..., 0::2] # (..., head_dim // 2)
|
||||||
|
x_odd = x[..., 1::2] # (..., head_dim // 2)
|
||||||
|
# Interleave: stack along new dim then flatten.
|
||||||
|
rotated = torch.stack([-x_odd, x_even], dim=-1)
|
||||||
|
# Shape: (..., head_dim // 2, 2) → (..., head_dim)
|
||||||
|
return rotated.flatten(start_dim=-2)
|
||||||
|
|
||||||
|
def apply(self, x: Tensor, offset: int = 0) -> Tensor:
|
||||||
|
"""
|
||||||
|
Apply Rotary Position Embeddings to ``x``.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
x : Tensor
|
||||||
|
Query or key tensor of shape
|
||||||
|
``(batch, num_heads, seq_len, head_dim)``.
|
||||||
|
offset : int
|
||||||
|
Absolute position of ``x[..., 0, :]`` in the full sequence.
|
||||||
|
|
||||||
|
This is what makes incremental decoding correct. With a KV cache the
|
||||||
|
model feeds one token at a time, so ``seq_len == 1`` and the naive
|
||||||
|
``cos_cache[:1]`` would rotate every generated token as if it were at
|
||||||
|
position 0 -- destroying all positional information after the prompt.
|
||||||
|
Passing ``offset=len(cache)`` selects the true absolute position.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tensor
|
||||||
|
Rotated tensor with the same shape and dtype as ``x``.
|
||||||
|
|
||||||
|
Raises
|
||||||
|
------
|
||||||
|
ValueError
|
||||||
|
If ``seq_len`` exceeds ``max_seq_len``.
|
||||||
|
"""
|
||||||
|
seq_len = x.size(2)
|
||||||
|
if offset < 0:
|
||||||
|
raise ValueError(f"offset must be >= 0, got {offset}")
|
||||||
|
if offset + seq_len > self.max_seq_len:
|
||||||
|
raise ValueError(
|
||||||
|
f"Positions [{offset}, {offset + seq_len}) exceed RoPE cache size "
|
||||||
|
f"{self.max_seq_len}. Re-instantiate with a larger max_seq_len."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Retrieve cached values for this absolute position span.
|
||||||
|
# cos_cache: (seq_len, head_dim // 2)
|
||||||
|
# sin_cache: (seq_len, head_dim // 2)
|
||||||
|
cos = self.cos_cache[offset : offset + seq_len] # type: ignore[index]
|
||||||
|
sin = self.sin_cache[offset : offset + seq_len] # type: ignore[index]
|
||||||
|
|
||||||
|
# Expand to broadcast over batch and head dimensions:
|
||||||
|
# (1, 1, seq_len, head_dim // 2) → broadcasts with (B, H, T, D/2)
|
||||||
|
cos = cos.unsqueeze(0).unsqueeze(0)
|
||||||
|
sin = sin.unsqueeze(0).unsqueeze(0)
|
||||||
|
|
||||||
|
# Interleave cos and sin to match full head_dim.
|
||||||
|
# Each of (cos, sin) has shape (1, 1, T, D/2).
|
||||||
|
# We need (1, 1, T, D) by interleaving even positions with cos,
|
||||||
|
# odd positions with sin. The _rotate_half trick handles this:
|
||||||
|
#
|
||||||
|
# x_rot = x * cos_full + rotate_half(x) * sin_full
|
||||||
|
#
|
||||||
|
# where cos_full[..., 0::2] = cos and cos_full[..., 1::2] = cos,
|
||||||
|
# i.e. each cos value applies to both the even AND its paired odd slot.
|
||||||
|
# Achieved by repeating each half-dim value into both slots.
|
||||||
|
cos_full = cos.repeat_interleave(2, dim=-1) # (1, 1, T, D)
|
||||||
|
sin_full = sin.repeat_interleave(2, dim=-1) # (1, 1, T, D)
|
||||||
|
|
||||||
|
# Work in float32 for stability, then restore original dtype.
|
||||||
|
x_fp32 = x.float()
|
||||||
|
x_rot = x_fp32 * cos_full + self._rotate_half(x_fp32) * sin_full
|
||||||
|
return x_rot.to(x.dtype)
|
||||||
|
|
||||||
|
def forward(self, x: Tensor, offset: int = 0) -> Tensor:
|
||||||
|
"""Alias for :meth:`apply` to support ``nn.Sequential`` usage."""
|
||||||
|
return self.apply(x, offset=offset)
|
||||||
|
|
||||||
|
def extra_repr(self) -> str:
|
||||||
|
return (
|
||||||
|
f"head_dim={self.head_dim}, "
|
||||||
|
f"max_seq_len={self.max_seq_len}, "
|
||||||
|
f"base={self.base}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Functional helper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def apply_rope(
|
||||||
|
q: Tensor,
|
||||||
|
k: Tensor,
|
||||||
|
rope: RotaryPositionEmbedding,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> Tuple[Tensor, Tensor]:
|
||||||
|
"""
|
||||||
|
Apply the same RoPE instance to both query and key tensors.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
q : Tensor
|
||||||
|
Query tensor of shape ``(batch, num_heads, seq_len, head_dim)``.
|
||||||
|
k : Tensor
|
||||||
|
Key tensor of shape ``(batch, num_heads, seq_len, head_dim)``.
|
||||||
|
rope : RotaryPositionEmbedding
|
||||||
|
Pre-built RoPE module (carries the cos/sin cache on the correct device).
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
Tuple[Tensor, Tensor]
|
||||||
|
``(q_rot, k_rot)`` — rotated queries and keys.
|
||||||
|
"""
|
||||||
|
return rope.apply(q, offset=offset), rope.apply(k, offset=offset)
|
||||||
4
cyberslm_sft/configs/__init__.py
Normal file
4
cyberslm_sft/configs/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
# configs/__init__.py
|
||||||
|
from configs.sft_config import SFTConfig, default_config, save_config, load_config
|
||||||
|
|
||||||
|
__all__ = ["SFTConfig", "default_config", "save_config", "load_config"]
|
||||||
311
cyberslm_sft/configs/sft_config.py
Normal file
311
cyberslm_sft/configs/sft_config.py
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"""
|
||||||
|
CyberSLM SFT Configuration
|
||||||
|
===========================
|
||||||
|
Centralised dataclass for all hyperparameters and paths used
|
||||||
|
in the Supervised Instruction Fine-Tuning pipeline.
|
||||||
|
|
||||||
|
Path wiring
|
||||||
|
-----------
|
||||||
|
All default paths are resolved as absolute paths relative to the
|
||||||
|
``cyberslm_sft/`` project root so the pipeline works regardless of
|
||||||
|
where you invoke it from.
|
||||||
|
|
||||||
|
Quick setup — copy Stage 1 artifacts into the SFT project::
|
||||||
|
|
||||||
|
# 1. Copy tokenizer
|
||||||
|
cp "SLm Dataset/tokenizer/tokenizer_output/tokenizer.model" \\
|
||||||
|
"SLm Dataset/cyberslm_sft/tokenizer/tokenizer.model"
|
||||||
|
|
||||||
|
# 2. Copy pretrained weights
|
||||||
|
cp "SLm Dataset/cyberslm/checkpoints/best.pt" \\
|
||||||
|
"SLm Dataset/cyberslm_sft/checkpoints/pretrained/model.pt"
|
||||||
|
|
||||||
|
# 3. Put your instruction dataset in data/
|
||||||
|
# data/train.jsonl (required)
|
||||||
|
# data/val.jsonl (optional)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Project root resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _PROJECT_ROOT = .../cyberslm_sft/
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# Stage 1 dataset root (one level above cyberslm_sft/) — used only as fallback
|
||||||
|
_DATASET_ROOT = _PROJECT_ROOT.parent # .../SLm Dataset/
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Model Architecture (must match pretrained checkpoint — never modify here)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelConfig:
|
||||||
|
"""
|
||||||
|
Mirrors the pretrained CyberSLM architecture exactly.
|
||||||
|
These values are frozen; do NOT change them for SFT.
|
||||||
|
"""
|
||||||
|
vocab_size: int = 32_000
|
||||||
|
hidden_size: int = 384
|
||||||
|
num_layers: int = 12
|
||||||
|
num_heads: int = 6
|
||||||
|
head_dim: int = 64
|
||||||
|
ffn_size: int = 1024
|
||||||
|
max_seq_len: int = 2048 # MUST match the pretrained checkpoint
|
||||||
|
weight_tying: bool = True
|
||||||
|
bias: bool = False
|
||||||
|
dropout: float = 0.0
|
||||||
|
norm_eps: float = 1e-6 # RMSNorm epsilon — must match pretraining
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tokenizer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TokenizerConfig:
|
||||||
|
"""
|
||||||
|
Paths and special token ids for the SentencePiece BPE tokenizer.
|
||||||
|
|
||||||
|
model_path
|
||||||
|
Absolute path to ``tokenizer.model``.
|
||||||
|
Default: ``<project_root>/tokenizer/tokenizer.model``
|
||||||
|
(auto-falls back to Stage 1 canonical path if the local copy is absent)
|
||||||
|
"""
|
||||||
|
model_path: str = str(_PROJECT_ROOT / "tokenizer" / "tokenizer.model")
|
||||||
|
# Real SentencePiece token ids — verified against tokenizer.model
|
||||||
|
pad_id: int = 0
|
||||||
|
bos_id: int = 2 # <s> (NOT 1)
|
||||||
|
eos_id: int = 3 # </s> (NOT 2)
|
||||||
|
unk_id: int = 1 # <unk>
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DataConfig:
|
||||||
|
"""Dataset paths and processing knobs."""
|
||||||
|
train_path: str = str(_PROJECT_ROOT / "data" / "SFT.jsonl")
|
||||||
|
val_path: str = "" # empty = auto-split from train_path using val_split
|
||||||
|
|
||||||
|
# Maximum token length per sample (hard-truncate if exceeded).
|
||||||
|
# Must be <= ModelConfig.max_seq_len.
|
||||||
|
max_seq_len: int = 2048
|
||||||
|
|
||||||
|
# Fraction of training data to use as validation when no val_path
|
||||||
|
# is provided (ignored if val_path exists).
|
||||||
|
val_split: float = 0.05
|
||||||
|
|
||||||
|
# DataLoader workers (set 0 for debugging)
|
||||||
|
num_workers: int = 0
|
||||||
|
prefetch_factor: Optional[int] = None
|
||||||
|
|
||||||
|
# Whether to shuffle the training set each epoch
|
||||||
|
shuffle: bool = True
|
||||||
|
|
||||||
|
# Seed used for the val split and shuffling
|
||||||
|
seed: int = 42
|
||||||
|
|
||||||
|
# Cap on total samples loaded (-1 = no cap); useful for quick smoke tests
|
||||||
|
max_samples: int = -1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Training
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrainConfig:
|
||||||
|
"""
|
||||||
|
Optimizer, scheduler, and loop settings.
|
||||||
|
|
||||||
|
LR is intentionally much lower than typical pretraining (~1e-3 to 3e-4).
|
||||||
|
SFT is refinement, not relearning.
|
||||||
|
"""
|
||||||
|
# ---- Optimiser ----
|
||||||
|
learning_rate: float = 2e-5 # Peak LR after warmup
|
||||||
|
weight_decay: float = 0.01
|
||||||
|
beta1: float = 0.9
|
||||||
|
beta2: float = 0.95
|
||||||
|
eps: float = 1e-8
|
||||||
|
|
||||||
|
# ---- Gradient ----
|
||||||
|
max_grad_norm: float = 1.0
|
||||||
|
gradient_accumulation_steps: int = 4 # Effective batch = batch_size × accum
|
||||||
|
|
||||||
|
# ---- Batch ----
|
||||||
|
per_device_batch_size: int = 4
|
||||||
|
|
||||||
|
# ---- Schedule ----
|
||||||
|
num_epochs: int = 3
|
||||||
|
warmup_ratio: float = 0.03 # Fraction of total steps for warmup
|
||||||
|
lr_schedule: str = "cosine" # "cosine" | "linear" | "constant"
|
||||||
|
min_lr_ratio: float = 0.1 # min_lr = learning_rate × min_lr_ratio
|
||||||
|
|
||||||
|
# ---- Precision ----
|
||||||
|
dtype: str = "bfloat16" # bf16 on GPU; auto-disabled on CPU
|
||||||
|
|
||||||
|
# ---- Reproducibility ----
|
||||||
|
seed: int = 42
|
||||||
|
|
||||||
|
# ---- Logging ----
|
||||||
|
log_every_n_steps: int = 10
|
||||||
|
eval_every_n_steps: int = 200 # 0 = eval only at epoch end
|
||||||
|
save_every_n_steps: int = 500 # 0 = save only at epoch end
|
||||||
|
|
||||||
|
# ---- Output ----
|
||||||
|
output_dir: str = str(_PROJECT_ROOT / "checkpoints")
|
||||||
|
run_name: str = "cyberslm-instruct"
|
||||||
|
|
||||||
|
# ---- Resume ----
|
||||||
|
resume_from_checkpoint: Optional[str] = None # Path to checkpoint dir
|
||||||
|
|
||||||
|
# ---- Base model ----
|
||||||
|
pretrained_checkpoint: str = str(
|
||||||
|
_PROJECT_ROOT / "checkpoints" / "pretrained" / "model.pt"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- Inference sanity check ----
|
||||||
|
run_inference_test: bool = True
|
||||||
|
max_new_tokens: int = 256
|
||||||
|
temperature: float = 0.7
|
||||||
|
top_p: float = 0.9
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompt / Template
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TemplateConfig:
|
||||||
|
"""
|
||||||
|
Controls which prompt format is used to wrap instruction samples.
|
||||||
|
The SFT loss is only computed on the *response* portion.
|
||||||
|
"""
|
||||||
|
# Token that separates instruction/input from the response.
|
||||||
|
# Loss masking begins immediately after this string (inclusive of the
|
||||||
|
# newline that follows it).
|
||||||
|
response_prefix: str = "### Response:\n"
|
||||||
|
|
||||||
|
# NOTE: end-of-sequence is handled as a real token id (``<eos>`` == 3),
|
||||||
|
# NOT a literal string. Appending the characters ``"</s>"`` would train
|
||||||
|
# the model to emit text that the sampler never stops on. Kept as an empty
|
||||||
|
# string so no literal EOS text is injected anywhere in the pipeline.
|
||||||
|
eos_string: str = ""
|
||||||
|
|
||||||
|
# Whether to strip leading/trailing whitespace from each field
|
||||||
|
strip_fields: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Master Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SFTConfig:
|
||||||
|
"""
|
||||||
|
Top-level configuration. Pass this object through the entire pipeline.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from configs.sft_config import SFTConfig, load_config, save_config
|
||||||
|
|
||||||
|
cfg = SFTConfig()
|
||||||
|
# or
|
||||||
|
cfg = load_config("my_run/sft_config.json")
|
||||||
|
"""
|
||||||
|
model: ModelConfig = field(default_factory=ModelConfig)
|
||||||
|
tokenizer: TokenizerConfig = field(default_factory=TokenizerConfig)
|
||||||
|
data: DataConfig = field(default_factory=DataConfig)
|
||||||
|
train: TrainConfig = field(default_factory=TrainConfig)
|
||||||
|
template: TemplateConfig = field(default_factory=TemplateConfig)
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
# Ensure max_seq_len for data never exceeds the model's context window
|
||||||
|
if self.data.max_seq_len > self.model.max_seq_len:
|
||||||
|
raise ValueError(
|
||||||
|
f"DataConfig.max_seq_len ({self.data.max_seq_len}) exceeds "
|
||||||
|
f"ModelConfig.max_seq_len ({self.model.max_seq_len}). "
|
||||||
|
"Truncate to the model context window or below."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Serialisation helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def save_config(cfg: SFTConfig, path: str | Path) -> None:
|
||||||
|
"""Serialise an SFTConfig to a JSON file."""
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(asdict(cfg), fh, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path) -> SFTConfig:
|
||||||
|
"""
|
||||||
|
Deserialise an SFTConfig from a JSON file produced by ``save_config``.
|
||||||
|
Provides forward-compatibility: unknown keys in the file are silently
|
||||||
|
ignored so old checkpoints can still be loaded after field additions.
|
||||||
|
"""
|
||||||
|
with open(path, "r", encoding="utf-8") as fh:
|
||||||
|
raw: dict = json.load(fh)
|
||||||
|
|
||||||
|
def _filter(dc_type, d: dict) -> dict:
|
||||||
|
"""Return only keys that exist in the target dataclass."""
|
||||||
|
valid = {f.name for f in dc_type.__dataclass_fields__.values()} # type: ignore[attr-defined]
|
||||||
|
return {k: v for k, v in d.items() if k in valid}
|
||||||
|
|
||||||
|
model_cfg = ModelConfig(**_filter(ModelConfig, raw.get("model", {})))
|
||||||
|
tok_cfg = TokenizerConfig(**_filter(TokenizerConfig, raw.get("tokenizer", {})))
|
||||||
|
data_cfg = DataConfig(**_filter(DataConfig, raw.get("data", {})))
|
||||||
|
train_cfg = TrainConfig(**_filter(TrainConfig, raw.get("train", {})))
|
||||||
|
tmpl_cfg = TemplateConfig(**_filter(TemplateConfig, raw.get("template", {})))
|
||||||
|
|
||||||
|
return SFTConfig(
|
||||||
|
model=model_cfg,
|
||||||
|
tokenizer=tok_cfg,
|
||||||
|
data=data_cfg,
|
||||||
|
train=train_cfg,
|
||||||
|
template=tmpl_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def default_config() -> SFTConfig:
|
||||||
|
"""
|
||||||
|
Return a default SFTConfig with all paths resolved to absolute paths
|
||||||
|
inside the ``cyberslm_sft/`` project root.
|
||||||
|
|
||||||
|
Tokenizer fallback
|
||||||
|
------------------
|
||||||
|
If ``tokenizer/tokenizer.model`` doesn't exist locally, the function
|
||||||
|
automatically falls back to the canonical Stage 1 path at
|
||||||
|
``../tokenizer/tokenizer_output/tokenizer.model``.
|
||||||
|
"""
|
||||||
|
cfg = SFTConfig()
|
||||||
|
|
||||||
|
# Auto-fallback to Stage 1 tokenizer if local copy is absent
|
||||||
|
local_tok = Path(cfg.tokenizer.model_path)
|
||||||
|
canonical_tok = _DATASET_ROOT / "tokenizer" / "tokenizer_output" / "tokenizer.model"
|
||||||
|
if not local_tok.exists() and canonical_tok.exists():
|
||||||
|
import warnings
|
||||||
|
warnings.warn(
|
||||||
|
f"Local tokenizer not found at {local_tok}.\n"
|
||||||
|
f"Falling back to Stage 1 tokenizer at {canonical_tok}.\n"
|
||||||
|
"Copy it with:\n"
|
||||||
|
f" cp '{canonical_tok}' '{local_tok}'",
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
cfg.tokenizer.model_path = str(canonical_tok)
|
||||||
|
|
||||||
|
return cfg
|
||||||
9
cyberslm_sft/data/__init__.py
Normal file
9
cyberslm_sft/data/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# data/__init__.py (inference-only subset)
|
||||||
|
#
|
||||||
|
# The full package also re-exports the dataset loader, validator, collator and
|
||||||
|
# loss masking. Those are training-only and are deliberately not shipped here,
|
||||||
|
# so importing them would fail. Only the prompt formatter is needed to build a
|
||||||
|
# prompt the model recognises.
|
||||||
|
from data.prompt_formatter import PromptFormatter, Tokenizer, IGNORE_INDEX
|
||||||
|
|
||||||
|
__all__ = ["PromptFormatter", "Tokenizer", "IGNORE_INDEX"]
|
||||||
348
cyberslm_sft/data/prompt_formatter.py
Normal file
348
cyberslm_sft/data/prompt_formatter.py
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
"""
|
||||||
|
CyberSLM SFT — Prompt Formatter
|
||||||
|
================================
|
||||||
|
Converts normalised raw samples into tokenised ``(input_ids, labels)``
|
||||||
|
pairs ready for the data collator.
|
||||||
|
|
||||||
|
Tokenisation strategy (segment-based)
|
||||||
|
-------------------------------------
|
||||||
|
A sample is decomposed into an ordered list of ``(text, is_loss_target)``
|
||||||
|
segments (see ``ConversationTemplate`` for the canonical layout). Each
|
||||||
|
segment is encoded independently and the resulting token id lists are
|
||||||
|
concatenated to form ``input_ids``. ``labels`` mirror ``input_ids`` but with
|
||||||
|
non-target (prompt) positions set to ``IGNORE_INDEX`` (-100).
|
||||||
|
|
||||||
|
Why segment-based (and not char-offset) masking
|
||||||
|
------------------------------------------------
|
||||||
|
Locating the response boundary by re-encoding a prefix string and comparing
|
||||||
|
token counts is fragile: with ``add_dummy_prefix`` (and BPE merges across the
|
||||||
|
boundary) ``len(encode(prefix))`` need not equal the number of full-sequence
|
||||||
|
tokens covering that prefix, so the mask can drift by 1-2 tokens per turn.
|
||||||
|
Encoding each segment and concatenating gives an **exact** boundary because
|
||||||
|
``input_ids`` and ``labels`` are built from the very same token lists.
|
||||||
|
|
||||||
|
Special tokens (critical)
|
||||||
|
--------------------------
|
||||||
|
Special tokens are referenced by **id**, never as literal strings. The real
|
||||||
|
SentencePiece control ids are ``<bos>=2`` and ``<eos>=3``. Every assistant
|
||||||
|
response is terminated with ``eos_id`` as a genuine token and that ``eos_id``
|
||||||
|
is **left unmasked** so the model learns to stop. ``bos_id`` is prepended once
|
||||||
|
at the start of the sequence (masked). This replaces the previous, broken
|
||||||
|
approach of appending the literal characters ``"</s>"`` (which SentencePiece
|
||||||
|
encodes as ``<``,``/``,``s``,``>`` — never id 3), which prevented the model
|
||||||
|
from ever learning an end-of-sequence signal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
|
||||||
|
from configs.sft_config import SFTConfig, TemplateConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Tokens with this label are excluded from the cross-entropy loss.
|
||||||
|
IGNORE_INDEX: int = -100
|
||||||
|
|
||||||
|
# One segment of the rendered prompt: (text, is_loss_target).
|
||||||
|
Segment = Tuple[str, bool]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Segment builders (string level, EOS handled at token level downstream)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def build_alpaca_segments(
|
||||||
|
instruction: str,
|
||||||
|
output: str,
|
||||||
|
input_text: str = "",
|
||||||
|
tmpl: Optional[TemplateConfig] = None,
|
||||||
|
include_response: bool = True,
|
||||||
|
) -> List[Segment]:
|
||||||
|
"""
|
||||||
|
Build the ordered ``(text, is_loss_target)`` segments for an alpaca sample.
|
||||||
|
|
||||||
|
The response text is a loss target; everything else (instruction, input,
|
||||||
|
the response header) is masked. No EOS string is injected here — the
|
||||||
|
end-of-sequence token is appended as a real token id by
|
||||||
|
:func:`encode_segments`.
|
||||||
|
"""
|
||||||
|
if tmpl is None:
|
||||||
|
tmpl = TemplateConfig()
|
||||||
|
|
||||||
|
if tmpl.strip_fields:
|
||||||
|
instruction = instruction.strip()
|
||||||
|
input_text = input_text.strip()
|
||||||
|
output = output.strip()
|
||||||
|
|
||||||
|
if input_text:
|
||||||
|
prompt = (
|
||||||
|
f"### Instruction:\n{instruction}\n\n"
|
||||||
|
f"### Input:\n{input_text}\n\n"
|
||||||
|
f"{tmpl.response_prefix}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
prompt = (
|
||||||
|
f"### Instruction:\n{instruction}\n\n"
|
||||||
|
f"{tmpl.response_prefix}"
|
||||||
|
)
|
||||||
|
|
||||||
|
segments: List[Segment] = [(prompt, False)]
|
||||||
|
if include_response:
|
||||||
|
segments.append((output, True))
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
def build_conversation_segments(
|
||||||
|
messages: List[dict],
|
||||||
|
tmpl: Optional[TemplateConfig] = None,
|
||||||
|
include_final_response: bool = True,
|
||||||
|
) -> List[Segment]:
|
||||||
|
"""
|
||||||
|
Build ordered ``(text, is_loss_target)`` segments for a multi-turn
|
||||||
|
conversation. Only assistant response bodies are loss targets.
|
||||||
|
"""
|
||||||
|
if tmpl is None:
|
||||||
|
tmpl = TemplateConfig()
|
||||||
|
|
||||||
|
def _clean(s: str) -> str:
|
||||||
|
return s.strip() if tmpl.strip_fields else s
|
||||||
|
|
||||||
|
segments: List[Segment] = []
|
||||||
|
|
||||||
|
# Optional leading system message.
|
||||||
|
body = messages
|
||||||
|
if messages and messages[0].get("role") == "system":
|
||||||
|
sys_content = _clean(messages[0]["content"])
|
||||||
|
if sys_content:
|
||||||
|
segments.append((f"System: {sys_content}\n\n", False))
|
||||||
|
body = messages[1:]
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
n = len(body)
|
||||||
|
while i < n:
|
||||||
|
role = body[i]["role"]
|
||||||
|
content = _clean(body[i]["content"])
|
||||||
|
|
||||||
|
if role == "user":
|
||||||
|
segments.append((f"### User:\n{content}\n\n", False))
|
||||||
|
# Pair with the following assistant turn, if present.
|
||||||
|
if i + 1 < n and body[i + 1]["role"] == "assistant":
|
||||||
|
asst = _clean(body[i + 1]["content"])
|
||||||
|
is_last = (i + 2 >= n)
|
||||||
|
segments.append(("### Assistant:\n", False))
|
||||||
|
if not (is_last and not include_final_response):
|
||||||
|
segments.append((asst, True))
|
||||||
|
segments.append(("\n\n", False))
|
||||||
|
i += 2
|
||||||
|
else:
|
||||||
|
# Dangling user turn with no assistant reply -- the normal
|
||||||
|
# generation case. The assistant header MUST still be emitted:
|
||||||
|
# it is the cue the model was trained to continue from. Omitting
|
||||||
|
# it (the previous behaviour) handed the model a prompt shaped
|
||||||
|
# unlike anything in its training distribution.
|
||||||
|
segments.append(("### Assistant:\n", False))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
elif role == "assistant":
|
||||||
|
asst = content
|
||||||
|
segments.append(("### Assistant:\n", False))
|
||||||
|
segments.append((asst, True))
|
||||||
|
segments.append(("\n\n", False))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Mid-conversation system message: treat as masked context.
|
||||||
|
segments.append((f"### System:\n{content}\n\n", False))
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return segments
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tokeniser wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class Tokenizer:
|
||||||
|
"""
|
||||||
|
Thin wrapper around a SentencePiece model that exposes only what the
|
||||||
|
formatter and collator need.
|
||||||
|
|
||||||
|
SentencePiece is not imported at module level so the formatter module can
|
||||||
|
be imported in unit tests without requiring sentencepiece to be installed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, model_path: str) -> None:
|
||||||
|
try:
|
||||||
|
import sentencepiece as spm # type: ignore
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"sentencepiece is required for the tokenizer. "
|
||||||
|
"Install with: pip install sentencepiece"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._sp = spm.SentencePieceProcessor()
|
||||||
|
self._sp.Load(model_path)
|
||||||
|
|
||||||
|
self.bos_id: int = self._sp.bos_id()
|
||||||
|
self.eos_id: int = self._sp.eos_id()
|
||||||
|
self.pad_id: int = self._sp.pad_id()
|
||||||
|
self.vocab_size: int = self._sp.GetPieceSize()
|
||||||
|
|
||||||
|
# Fail loudly if the tokenizer's control ids do not match the ids the
|
||||||
|
# rest of the pipeline (and the base model config) assume. A silent
|
||||||
|
# mismatch here corrupts training and breaks generation stopping.
|
||||||
|
if self.eos_id < 0:
|
||||||
|
raise ValueError(
|
||||||
|
"Tokenizer has no EOS id (eos_id < 0). The SFT pipeline "
|
||||||
|
"requires a real end-of-sequence token."
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def encode(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
add_bos: bool = False,
|
||||||
|
add_eos: bool = False,
|
||||||
|
) -> List[int]:
|
||||||
|
ids: List[int] = self._sp.Encode(text, out_type=int)
|
||||||
|
if add_bos and self.bos_id >= 0:
|
||||||
|
ids = [self.bos_id] + ids
|
||||||
|
if add_eos and self.eos_id >= 0:
|
||||||
|
ids = ids + [self.eos_id]
|
||||||
|
return ids
|
||||||
|
|
||||||
|
def decode(self, ids: List[int]) -> str:
|
||||||
|
return self._sp.Decode(ids)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return self.vocab_size
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Segment → token id encoding
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def encode_segments(
|
||||||
|
segments: List[Segment],
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
max_seq_len: int,
|
||||||
|
add_bos: bool = True,
|
||||||
|
) -> Tuple[List[int], List[int]]:
|
||||||
|
"""
|
||||||
|
Encode ``(text, is_loss_target)`` segments into ``(input_ids, labels)``.
|
||||||
|
|
||||||
|
* ``input_ids`` is the concatenation of each segment's token ids.
|
||||||
|
* A real ``eos_id`` token is appended immediately after every loss-target
|
||||||
|
(assistant) segment and is itself a loss target — the model must learn
|
||||||
|
to emit it.
|
||||||
|
* ``bos_id`` is prepended once at the start (masked) when ``add_bos``.
|
||||||
|
* ``labels`` equal ``input_ids`` on target positions and ``IGNORE_INDEX``
|
||||||
|
elsewhere.
|
||||||
|
|
||||||
|
The sequence is truncated to ``max_seq_len`` tokens.
|
||||||
|
"""
|
||||||
|
input_ids: List[int] = []
|
||||||
|
labels: List[int] = []
|
||||||
|
|
||||||
|
if add_bos and tokenizer.bos_id is not None and tokenizer.bos_id >= 0:
|
||||||
|
input_ids.append(tokenizer.bos_id)
|
||||||
|
labels.append(IGNORE_INDEX)
|
||||||
|
|
||||||
|
for text, is_target in segments:
|
||||||
|
ids = tokenizer.encode(text)
|
||||||
|
input_ids.extend(ids)
|
||||||
|
labels.extend(ids if is_target else [IGNORE_INDEX] * len(ids))
|
||||||
|
if is_target:
|
||||||
|
# Terminate the assistant turn with a learned EOS token.
|
||||||
|
input_ids.append(tokenizer.eos_id)
|
||||||
|
labels.append(tokenizer.eos_id)
|
||||||
|
|
||||||
|
return input_ids[:max_seq_len], labels[:max_seq_len]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API — PromptFormatter
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PromptFormatter:
|
||||||
|
"""
|
||||||
|
Converts a normalised raw sample into a tokenised ``(input_ids, labels)``
|
||||||
|
pair, applying loss-masking so only assistant responses are trained on.
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
cfg:
|
||||||
|
Master ``SFTConfig`` (for template and data settings).
|
||||||
|
tokenizer:
|
||||||
|
Initialised ``Tokenizer`` instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, cfg: SFTConfig, tokenizer: Tokenizer) -> None:
|
||||||
|
self.cfg = cfg
|
||||||
|
self.tokenizer = tokenizer
|
||||||
|
self.tmpl = cfg.template
|
||||||
|
self.max_len = cfg.data.max_seq_len
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def format(self, sample: dict) -> Optional[Tuple[List[int], List[int]]]:
|
||||||
|
"""
|
||||||
|
Format a single normalised sample into ``(input_ids, labels)``.
|
||||||
|
|
||||||
|
Returns ``None`` when the sample produces no trainable (non-masked)
|
||||||
|
tokens — e.g. the response was truncated away.
|
||||||
|
"""
|
||||||
|
if "messages" in sample:
|
||||||
|
segments = build_conversation_segments(
|
||||||
|
sample["messages"], tmpl=self.tmpl, include_final_response=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
segments = build_alpaca_segments(
|
||||||
|
instruction=sample["instruction"],
|
||||||
|
output=sample["output"],
|
||||||
|
input_text=sample.get("input", ""),
|
||||||
|
tmpl=self.tmpl,
|
||||||
|
include_response=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nothing to train on if there are no target segments at all.
|
||||||
|
if not any(is_target for _, is_target in segments):
|
||||||
|
logger.debug("No assistant/response segments — skipping sample")
|
||||||
|
return None
|
||||||
|
|
||||||
|
input_ids, labels = encode_segments(
|
||||||
|
segments, self.tokenizer, self.max_len, add_bos=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if not input_ids or not any(l != IGNORE_INDEX for l in labels):
|
||||||
|
logger.debug(
|
||||||
|
"No response tokens remain after truncation at %d — skipping",
|
||||||
|
self.max_len,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
return input_ids, labels
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def format_for_inference(self, sample: dict) -> List[int]:
|
||||||
|
"""
|
||||||
|
Format a sample for *generation*: prompt only, no response body and no
|
||||||
|
trailing EOS, with ``bos_id`` prepended.
|
||||||
|
"""
|
||||||
|
if "messages" in sample:
|
||||||
|
segments = build_conversation_segments(
|
||||||
|
sample["messages"], tmpl=self.tmpl, include_final_response=False
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
segments = build_alpaca_segments(
|
||||||
|
instruction=sample["instruction"],
|
||||||
|
output=sample.get("output", ""),
|
||||||
|
input_text=sample.get("input", ""),
|
||||||
|
tmpl=self.tmpl,
|
||||||
|
include_response=False,
|
||||||
|
)
|
||||||
|
input_ids, _ = encode_segments(
|
||||||
|
segments, self.tokenizer, self.max_len, add_bos=True
|
||||||
|
)
|
||||||
|
return input_ids
|
||||||
3
cyberslm_sft/model/__init__.py
Normal file
3
cyberslm_sft/model/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .cyberslm import CyberSLM
|
||||||
|
|
||||||
|
__all__ = ["CyberSLM"]
|
||||||
40
cyberslm_sft/model/cyberslm.py
Normal file
40
cyberslm_sft/model/cyberslm.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Add the root directory (which contains 'cyberslm') to sys.path
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent))
|
||||||
|
|
||||||
|
from cyberslm.model.model import CyberSLM as Stage1CyberSLM
|
||||||
|
from cyberslm.model.config import CyberSLMConfig
|
||||||
|
|
||||||
|
class CyberSLM(Stage1CyberSLM):
|
||||||
|
"""
|
||||||
|
Adapter class to bridge SFT's ModelConfig to Stage 1's CyberSLMConfig,
|
||||||
|
allowing us to use the original Stage 1 model architecture verbatim.
|
||||||
|
"""
|
||||||
|
def __init__(self, cfg):
|
||||||
|
# Convert SFT ModelConfig to Stage 1 CyberSLMConfig
|
||||||
|
stage1_cfg = CyberSLMConfig(
|
||||||
|
vocab_size=cfg.vocab_size,
|
||||||
|
hidden_dim=cfg.hidden_size,
|
||||||
|
num_layers=cfg.num_layers,
|
||||||
|
num_heads=cfg.num_heads,
|
||||||
|
head_dim=cfg.head_dim,
|
||||||
|
ffn_hidden_dim=cfg.ffn_size,
|
||||||
|
max_seq_len=cfg.max_seq_len,
|
||||||
|
tie_weights=cfg.weight_tying,
|
||||||
|
bias=cfg.bias,
|
||||||
|
dropout=cfg.dropout,
|
||||||
|
norm_eps=cfg.norm_eps,
|
||||||
|
)
|
||||||
|
super().__init__(stage1_cfg)
|
||||||
|
|
||||||
|
def forward(self, input_ids, attention_mask=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Wrapper around Stage 1's forward pass.
|
||||||
|
Stage 1 returns (logits, all_attn_weights); the SFT trainer expects
|
||||||
|
just the logits Tensor. The key-padding ``attention_mask`` (1=keep,
|
||||||
|
0=pad) is forwarded so right-padded batches do not corrupt real tokens.
|
||||||
|
"""
|
||||||
|
logits, _ = super().forward(input_ids, attention_mask=attention_mask)
|
||||||
|
return logits
|
||||||
10
generation_config.json
Normal file
10
generation_config.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"_from_model_config": true,
|
||||||
|
"bos_token_id": 2,
|
||||||
|
"eos_token_id": 3,
|
||||||
|
"output_attentions": false,
|
||||||
|
"output_hidden_states": false,
|
||||||
|
"pad_token_id": 0,
|
||||||
|
"transformers_version": "5.15.1",
|
||||||
|
"use_cache": true
|
||||||
|
}
|
||||||
118
infer_chat.py
Normal file
118
infer_chat.py
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
Instruction-tuned model inference — question answering.
|
||||||
|
|
||||||
|
python Final/infer_chat.py --prompt "What is SQL injection?"
|
||||||
|
python Final/infer_chat.py --interactive
|
||||||
|
|
||||||
|
The prompt is built with the SAME formatter used during fine-tuning, so the
|
||||||
|
model sees exactly the token sequence it was trained on. Hand-assembling the
|
||||||
|
prompt string instead produces different token ids at every segment boundary
|
||||||
|
(SentencePiece prepends a word-start marker per encode call) and the model then
|
||||||
|
sees something it was never trained on.
|
||||||
|
|
||||||
|
Expect correctly-shaped answers with unreliable facts: this is a 33.5M-parameter
|
||||||
|
model. See runs/reports/FINAL_REPORT.md for measured behaviour.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import torch
|
||||||
|
|
||||||
|
_HERE = Path(__file__).resolve().parent
|
||||||
|
for _p in (_HERE, _HERE / "cyberslm_sft"):
|
||||||
|
if str(_p) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_p))
|
||||||
|
|
||||||
|
from configs.sft_config import default_config # noqa: E402
|
||||||
|
from data.prompt_formatter import PromptFormatter, Tokenizer # noqa: E402
|
||||||
|
from model.cyberslm import CyberSLM as SFTModel # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="CyberSLM instruct (question answering)")
|
||||||
|
ap.add_argument("--prompt", "-p", default="What is SQL injection and how do I prevent it?")
|
||||||
|
ap.add_argument("--interactive", "-i", action="store_true")
|
||||||
|
ap.add_argument("--checkpoint", "-c", default=str(_HERE / "models" / "instruct.pt"))
|
||||||
|
ap.add_argument("--tokenizer", default=str(_HERE / "tokenizer" / "tokenizer.model"))
|
||||||
|
ap.add_argument("--max-new-tokens", "-m", type=int, default=200)
|
||||||
|
ap.add_argument("--temperature", "-t", type=float, default=0.0,
|
||||||
|
help="0 = greedy/deterministic (recommended for this model)")
|
||||||
|
ap.add_argument("--top-k", type=int, default=50)
|
||||||
|
ap.add_argument("--top-p", type=float, default=0.9)
|
||||||
|
ap.add_argument("--repetition-penalty", type=float, default=1.1)
|
||||||
|
ap.add_argument("--device", default=None)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
device = torch.device(args.device) if args.device else torch.device(
|
||||||
|
"cuda" if torch.cuda.is_available() else "cpu")
|
||||||
|
|
||||||
|
ckpt = Path(args.checkpoint)
|
||||||
|
if not ckpt.exists():
|
||||||
|
print(f"Checkpoint not found: {ckpt}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
cfg = default_config()
|
||||||
|
cfg.tokenizer.model_path = args.tokenizer
|
||||||
|
cfg.model.max_seq_len = 2048
|
||||||
|
cfg.data.max_seq_len = 2048
|
||||||
|
|
||||||
|
tok = Tokenizer(cfg.tokenizer.model_path)
|
||||||
|
fmt = PromptFormatter(cfg=cfg, tokenizer=tok)
|
||||||
|
|
||||||
|
model = SFTModel(cfg.model)
|
||||||
|
state = torch.load(ckpt, map_location=device, weights_only=False)
|
||||||
|
if isinstance(state, dict) and "model_state" in state:
|
||||||
|
state = state["model_state"]
|
||||||
|
model.load_state_dict(state)
|
||||||
|
model.to(device).eval()
|
||||||
|
|
||||||
|
n = sum(p.numel() for p in model.parameters())
|
||||||
|
print(f"model : {ckpt.name} ({n:,} params)")
|
||||||
|
print(f"context: {cfg.model.max_seq_len} device: {device} "
|
||||||
|
f"decoding: {'greedy' if args.temperature == 0 else f'T={args.temperature}'}")
|
||||||
|
|
||||||
|
def answer(question: str) -> None:
|
||||||
|
ids = fmt.format_for_inference({"messages": [{"role": "user", "content": question}]})
|
||||||
|
x = torch.tensor([ids], dtype=torch.long, device=device)
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
out = model.generate(
|
||||||
|
x, max_new_tokens=args.max_new_tokens, temperature=args.temperature,
|
||||||
|
top_k=args.top_k, top_p=args.top_p,
|
||||||
|
repetition_penalty=args.repetition_penalty, eos_id=tok.eos_id,
|
||||||
|
)
|
||||||
|
dt = time.perf_counter() - t0
|
||||||
|
new = out[0, len(ids):].tolist()
|
||||||
|
stopped = tok.eos_id in new
|
||||||
|
if stopped:
|
||||||
|
new = new[: new.index(tok.eos_id)]
|
||||||
|
print("\n" + "-" * 66)
|
||||||
|
print(tok.decode(new).strip() or "(empty)")
|
||||||
|
print("-" * 66)
|
||||||
|
print(f"{len(new)} tokens in {dt:.2f}s ({len(new)/dt if dt else 0:.1f} tok/s), "
|
||||||
|
f"{'stopped on EOS' if stopped else 'hit token limit'}\n")
|
||||||
|
|
||||||
|
if args.interactive:
|
||||||
|
print("\nInstruct model - ask a question. ('exit' to quit)")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
q = input("\nYou: ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("\nBye."); break
|
||||||
|
if not q:
|
||||||
|
continue
|
||||||
|
if q.lower() in {"exit", "quit", "q"}:
|
||||||
|
print("Bye."); break
|
||||||
|
answer(q)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
answer(args.prompt)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
3
model.safetensors
Normal file
3
model.safetensors
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:a29dca58c1ac9b190015c5fce60425830a2ee0009117969cefcb9d7fab569a06
|
||||||
|
size 134137152
|
||||||
3
models/instruct.pt
Normal file
3
models/instruct.pt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:baf9e480ae7ce58282c74703b5eeac3d80403ec4cbe102c179382cda2d4926b6
|
||||||
|
size 134160315
|
||||||
6
special_tokens_map.json
Normal file
6
special_tokens_map.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"bos_token": "<bos>",
|
||||||
|
"eos_token": "<eos>",
|
||||||
|
"unk_token": "<unk>",
|
||||||
|
"pad_token": "<pad>"
|
||||||
|
}
|
||||||
279575
tokenizer.json
Normal file
279575
tokenizer.json
Normal file
File diff suppressed because it is too large
Load Diff
3
tokenizer/tokenizer.model
Normal file
3
tokenizer/tokenizer.model
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0c1a2ad32fd7d576e42bad69c99312896a077913a543a25af73831e3266a68e6
|
||||||
|
size 735456
|
||||||
9
tokenizer_config.json
Normal file
9
tokenizer_config.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"tokenizer_class": "PreTrainedTokenizerFast",
|
||||||
|
"model_max_length": 2048,
|
||||||
|
"bos_token": "<bos>",
|
||||||
|
"eos_token": "<eos>",
|
||||||
|
"unk_token": "<unk>",
|
||||||
|
"pad_token": "<pad>",
|
||||||
|
"clean_up_tokenization_spaces": false
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user