rpeft 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +29 -21
- data/lib/rpeft/cli/lora_train.rb +7 -3
- data/lib/rpeft/dataset.rb +20 -11
- data/lib/rpeft/lora/adapter.rb +2 -2
- data/lib/rpeft/lora.rb +24 -7
- data/lib/rpeft/version.rb +1 -1
- data/python/rpeft_backend/__init__.py +1 -1
- data/python/rpeft_backend/_chat.py +34 -0
- data/python/rpeft_backend/_data.py +104 -26
- data/python/rpeft_backend/_training.py +35 -24
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d6162af3628a1e82e72c85f6f1ebf42c1f4bd58298992d4bc7036766628595c4
|
|
4
|
+
data.tar.gz: 52b2ac5be8272a31978e751e6d388e010cc97625b8cabeea4f6f6690bad3cb52
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ac75ae251f4f228ee5e3fca1b104f7819d509c10644754fb15754ade5bc82d03af67e3619c1492c9fbf715ca26f82e3e673caaf493f46114fd100271e28760bc
|
|
7
|
+
data.tar.gz: a3a06a0a30dd6419f92180c4ce3fa12b20f1a0a0ae45e503f0cd9b25afd23611f3bb1ed580f7e60899ab42c4d05ec79f9d317919e01b12aa00cc5aaa8f5c3e5f
|
data/README.md
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# RPEFT
|
|
2
2
|
|
|
3
|
+
[](https://badge.fury.io/rb/rpeft)
|
|
4
|
+
[](https://github.com/koic/rpeft/actions/workflows/main.yml)
|
|
5
|
+
|
|
3
6
|
RPEFT brings LoRA fine-tuning of large language models to Ruby. It provides an API and CLI that model the LoRA lifecycle,
|
|
4
7
|
while the heavy lifting is done by the Python ecosystem (PyTorch, Hugging Face Transformers, PEFT, Datasets, Accelerate) through PyCall.
|
|
5
8
|
It is an independent project, not affiliated with Hugging Face.
|
|
@@ -15,16 +18,16 @@ It is an independent project, not affiliated with Hugging Face.
|
|
|
15
18
|
|
|
16
19
|
## The model behind the API
|
|
17
20
|
|
|
18
|
-
LoRA keeps the base model weights $W_0$ frozen and learns a low-rank update of rank $r$:
|
|
21
|
+
[LoRA](https://arxiv.org/abs/2106.09685) keeps the base model weights $W_0$ frozen and learns a low-rank update of rank $r$, scaled by $\alpha / r$:
|
|
19
22
|
|
|
20
23
|
```math
|
|
21
24
|
\begin{aligned}
|
|
22
25
|
W &= W_0 + \Delta W \\
|
|
23
|
-
\Delta W &\approx BA
|
|
26
|
+
\Delta W &\approx \frac{\alpha}{r} BA
|
|
24
27
|
\end{aligned}
|
|
25
28
|
```
|
|
26
29
|
|
|
27
|
-
$A$ and $B$ are small matrices that together typically hold well under 1% of the model's parameters. Training produces them; merging adds
|
|
30
|
+
$A$ and $B$ are small matrices that together typically hold well under 1% of the model's parameters. Training produces them; merging adds $\frac{\alpha}{r} BA$ back into $W_0$.
|
|
28
31
|
The API is the same shape:
|
|
29
32
|
|
|
30
33
|
```mermaid
|
|
@@ -34,7 +37,7 @@ flowchart LR
|
|
|
34
37
|
|
|
35
38
|
Merging is optional: an adapter can also generate on top of its base model as is.
|
|
36
39
|
|
|
37
|
-
The [`rank:`
|
|
40
|
+
The [`rank:` and `alpha:` options](#training-options) of `RPEFT::LoRA.train` are exactly the $r$ and $\alpha$ above.
|
|
38
41
|
|
|
39
42
|
## Requirements
|
|
40
43
|
|
|
@@ -96,7 +99,7 @@ adapter = RPEFT::LoRA.train(
|
|
|
96
99
|
)
|
|
97
100
|
adapter.save('./ruby-lora')
|
|
98
101
|
|
|
99
|
-
model = adapter.merge # W = W0 + B A
|
|
102
|
+
model = adapter.merge # W = W0 + (alpha / r) B A
|
|
100
103
|
model.save('./ruby-model')
|
|
101
104
|
```
|
|
102
105
|
|
|
@@ -176,21 +179,22 @@ The rule behind this also applies to choosing a dataset format: the prompt at in
|
|
|
176
179
|
|
|
177
180
|
### Training options
|
|
178
181
|
|
|
179
|
-
| Option
|
|
180
|
-
|
|
|
181
|
-
| `rank:`
|
|
182
|
-
| `alpha:`
|
|
183
|
-
| `dropout:`
|
|
184
|
-
| `target_modules:`
|
|
185
|
-
| `epochs:`
|
|
186
|
-
| `batch_size:`
|
|
187
|
-
| `learning_rate:`
|
|
188
|
-
| `max_length:`
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
193
|
-
| `
|
|
182
|
+
| Option | Default | Meaning |
|
|
183
|
+
| ------------------ | ------- | --------------------------------------------------------------------------------------- |
|
|
184
|
+
| `rank:` | `8` | LoRA rank $r$, the rank of the low-rank update $BA$ |
|
|
185
|
+
| `alpha:` | `16` | LoRA scaling factor $\alpha$ (`lora_alpha` in PEFT) |
|
|
186
|
+
| `dropout:` | `0.05` | LoRA dropout (`lora_dropout` in PEFT) |
|
|
187
|
+
| `target_modules:` | `nil` | Modules to adapt, e.g. `%w(q_proj v_proj)`. `nil` uses PEFT's per-architecture defaults |
|
|
188
|
+
| `epochs:` | `3` | Training epochs |
|
|
189
|
+
| `batch_size:` | `4` | Per-device batch size |
|
|
190
|
+
| `learning_rate:` | `2e-4` | Learning rate |
|
|
191
|
+
| `max_length:` | `512` | Max token length per example |
|
|
192
|
+
| `train_on_prompt:` | `false` | Compute the loss on prompts too, not only on answers (instruction and chat formats) |
|
|
193
|
+
| `eval_dataset:` | `nil` | Held-out dataset, in the same format as `dataset:`. Enables per-epoch evaluation |
|
|
194
|
+
| `checkpoints:` | `false` | Checkpoint every epoch so an interrupted run can resume; removed once training ends |
|
|
195
|
+
| `resume:` | `false` | Resume from the last checkpoint in `output_dir`, which must be given explicitly |
|
|
196
|
+
| `output_dir:` | `nil` | Adapter output directory: new, empty, or an earlier output. `nil` uses a temporary one |
|
|
197
|
+
| `device:` | `:auto` | `:auto`, `:cuda`, `:mps`, or `:cpu` |
|
|
194
198
|
|
|
195
199
|
### Dataset
|
|
196
200
|
|
|
@@ -215,6 +219,10 @@ the ones every chat template understands:
|
|
|
215
219
|
{"messages":[{"role":"user","content":"How do I reverse an array?"},{"role":"assistant","content":"[1, 2, 3].reverse"}]}
|
|
216
220
|
```
|
|
217
221
|
|
|
222
|
+
In instruction and chat data, the prompt is read as context, but the loss is computed on the answers alone: the `output`, or every assistant turn after the first message with its end-of-turn marker.
|
|
223
|
+
A `text` line has no such boundary, so it is trained on as a whole. Pass `train_on_prompt: true` to compute the loss on every token instead.
|
|
224
|
+
Examples whose answer does not fit within `max_length:` are skipped with a warning, and the number skipped is recorded in the adapter metadata.
|
|
225
|
+
|
|
218
226
|
`dataset:` also accepts any Enumerable of record Hashes in the same three shapes, so data built in Ruby needs no file of its own:
|
|
219
227
|
|
|
220
228
|
```ruby
|
|
@@ -248,7 +256,7 @@ RPEFT.check # => a Hash describing Python, packages, and devices
|
|
|
248
256
|
|
|
249
257
|
```console
|
|
250
258
|
$ rpeft doctor
|
|
251
|
-
Ruby: 4.0.6 (rpeft 0.
|
|
259
|
+
Ruby: 4.0.6 (rpeft 0.2.0)
|
|
252
260
|
Python: 3.12.13 (/path/to/.venv/bin/python)
|
|
253
261
|
torch: OK 2.13.0
|
|
254
262
|
transformers: OK 5.15.0
|
data/lib/rpeft/cli/lora_train.rb
CHANGED
|
@@ -4,8 +4,9 @@ module RPEFT
|
|
|
4
4
|
module CLI
|
|
5
5
|
# @api private
|
|
6
6
|
class LoRATrain < Command
|
|
7
|
-
PASSTHROUGH_OPTIONS = %i(
|
|
8
|
-
|
|
7
|
+
PASSTHROUGH_OPTIONS = %i(
|
|
8
|
+
rank alpha dropout target_modules epochs batch_size learning_rate max_length train_on_prompt device
|
|
9
|
+
).freeze
|
|
9
10
|
REQUIRED_OPTIONS = %i(model dataset output).freeze
|
|
10
11
|
DEVICES = %w(auto cuda mps cpu).freeze
|
|
11
12
|
|
|
@@ -26,7 +27,7 @@ module RPEFT
|
|
|
26
27
|
o.on('--rank N', Integer, 'LoRA rank r (default: 8)') do |v|
|
|
27
28
|
options[:rank] = v
|
|
28
29
|
end
|
|
29
|
-
o.on('--alpha N', Float, 'LoRA alpha (default: 16)') do |v|
|
|
30
|
+
o.on('--alpha N', Float, 'LoRA scaling factor alpha (default: 16)') do |v|
|
|
30
31
|
options[:alpha] = v
|
|
31
32
|
end
|
|
32
33
|
o.on('--dropout F', Float, 'LoRA dropout (default: 0.05)') do |v|
|
|
@@ -48,6 +49,9 @@ module RPEFT
|
|
|
48
49
|
o.on('--max-length N', Integer, 'Max token length (default: 512)') do |v|
|
|
49
50
|
options[:max_length] = v
|
|
50
51
|
end
|
|
52
|
+
o.on('--train-on-prompt', 'Also train on the prompt of instruction and chat examples') do
|
|
53
|
+
options[:train_on_prompt] = true
|
|
54
|
+
end
|
|
51
55
|
o.on('--device DEVICE', DEVICES,
|
|
52
56
|
'auto, cuda, mps, or cpu (default: auto)') { |v| options[:device] = v.to_sym }
|
|
53
57
|
end
|
data/lib/rpeft/dataset.rb
CHANGED
|
@@ -10,9 +10,11 @@ module RPEFT
|
|
|
10
10
|
#
|
|
11
11
|
# Three JSONL line formats are accepted and detected from the first line: `{"text": ...}` is
|
|
12
12
|
# passed through as is, `{"instruction": ..., "input": ..., "output": ...}` is rendered into
|
|
13
|
-
# a
|
|
13
|
+
# a prompt and its completion, and `{"messages": [{"role": ..., "content": ...}, ...]}` is passed through
|
|
14
14
|
# for the backend to render with the tokenizer's chat template.
|
|
15
15
|
class Dataset
|
|
16
|
+
BACKEND_FORMATS = {text: 'text', instruction: 'completion', messages: 'messages'}.freeze
|
|
17
|
+
|
|
16
18
|
def self.coerce(value)
|
|
17
19
|
return value if value.is_a?(Dataset)
|
|
18
20
|
return from_records(value) if value.is_a?(Enumerable)
|
|
@@ -63,10 +65,14 @@ module RPEFT
|
|
|
63
65
|
@temporary_dirs.clear
|
|
64
66
|
end
|
|
65
67
|
|
|
66
|
-
# The format the backend sees. Instruction datasets are normalized to
|
|
67
|
-
#
|
|
68
|
+
# The format the backend sees. Instruction datasets are normalized to prompt and completion pairs here,
|
|
69
|
+
# which keeps the boundary the backend needs to train on the completion alone.
|
|
68
70
|
def backend_format
|
|
69
|
-
format
|
|
71
|
+
BACKEND_FORMATS.fetch(format)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def messages?
|
|
75
|
+
format == :messages
|
|
70
76
|
end
|
|
71
77
|
|
|
72
78
|
private
|
|
@@ -112,8 +118,8 @@ module RPEFT
|
|
|
112
118
|
raise DatasetError, "dataset is not JSONL (#{e.message}): #{path}"
|
|
113
119
|
end
|
|
114
120
|
|
|
115
|
-
# Rewrites
|
|
116
|
-
#
|
|
121
|
+
# Rewrites an instruction dataset into prompt and completion JSONL in a temporary directory,
|
|
122
|
+
# so the backend needs no knowledge of the instruction template.
|
|
117
123
|
def normalize(source_path)
|
|
118
124
|
dir = Dir.mktmpdir('rpeft-dataset-')
|
|
119
125
|
@temporary_dirs << dir
|
|
@@ -124,7 +130,9 @@ module RPEFT
|
|
|
124
130
|
next if line.strip.empty?
|
|
125
131
|
|
|
126
132
|
record = parse_line(line, source_path)
|
|
127
|
-
|
|
133
|
+
prompt, completion = render_instruction(record, lineno, source_path)
|
|
134
|
+
|
|
135
|
+
out.puts JSON.generate('prompt' => prompt, 'completion' => completion)
|
|
128
136
|
end
|
|
129
137
|
end
|
|
130
138
|
|
|
@@ -142,10 +150,11 @@ module RPEFT
|
|
|
142
150
|
end
|
|
143
151
|
|
|
144
152
|
input = record['input']
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
153
|
+
prompt = "### Instruction:\n#{instruction}\n"
|
|
154
|
+
prompt << "\n### Input:\n#{input}\n" if input && !input.to_s.empty?
|
|
155
|
+
prompt << "\n### Response:\n"
|
|
156
|
+
|
|
157
|
+
[prompt, output]
|
|
149
158
|
end
|
|
150
159
|
end
|
|
151
160
|
end
|
data/lib/rpeft/lora/adapter.rb
CHANGED
|
@@ -84,8 +84,8 @@ module RPEFT
|
|
|
84
84
|
self
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
-
# Merges the learned
|
|
88
|
-
# (W = W0 + B A) and returns the resulting standalone Model.
|
|
87
|
+
# Merges the learned update into the base model weights
|
|
88
|
+
# (W = W0 + (alpha / r) B A) and returns the resulting standalone Model.
|
|
89
89
|
def merge(output_dir: nil)
|
|
90
90
|
unless base_model
|
|
91
91
|
raise MergeError, 'base model is unknown; pass base_model: to Adapter.load ' \
|
data/lib/rpeft/lora.rb
CHANGED
|
@@ -7,17 +7,18 @@ require 'tmpdir'
|
|
|
7
7
|
|
|
8
8
|
module RPEFT
|
|
9
9
|
# LoRA fine-tuning. Keeps the base model weights W0 frozen and learns
|
|
10
|
-
# a low-rank update
|
|
10
|
+
# a low-rank update of rank r, scaled by alpha / r:
|
|
11
11
|
#
|
|
12
|
-
# W = W0 +
|
|
12
|
+
# W = W0 + delta W
|
|
13
|
+
# delta W ~= (alpha / r) B A
|
|
13
14
|
#
|
|
14
|
-
# The `rank:`
|
|
15
|
+
# The `rank:` and `alpha:` options are exactly that r and alpha.
|
|
15
16
|
module LoRA
|
|
16
17
|
POSITIVE_INTEGER_OPTIONS = %i(rank epochs batch_size max_length).freeze
|
|
17
18
|
POSITIVE_NUMBER_OPTIONS = %i(alpha learning_rate).freeze
|
|
18
19
|
|
|
19
20
|
class << self
|
|
20
|
-
def train(model:, dataset:, eval_dataset: nil, rank: 8, alpha: 16, dropout: 0.05, target_modules: nil, epochs: 3, batch_size: 4, learning_rate: 2e-4, max_length: 512, output_dir: nil, device: :auto, checkpoints: false, resume: false)
|
|
21
|
+
def train(model:, dataset:, eval_dataset: nil, rank: 8, alpha: 16, dropout: 0.05, target_modules: nil, epochs: 3, batch_size: 4, learning_rate: 2e-4, max_length: 512, train_on_prompt: false, output_dir: nil, device: :auto, checkpoints: false, resume: false)
|
|
21
22
|
options = {
|
|
22
23
|
rank: rank,
|
|
23
24
|
alpha: alpha,
|
|
@@ -27,6 +28,7 @@ module RPEFT
|
|
|
27
28
|
batch_size: batch_size,
|
|
28
29
|
learning_rate: learning_rate,
|
|
29
30
|
max_length: max_length,
|
|
31
|
+
train_on_prompt: train_on_prompt,
|
|
30
32
|
checkpoints: checkpoints,
|
|
31
33
|
resume: resume
|
|
32
34
|
}
|
|
@@ -80,6 +82,13 @@ module RPEFT
|
|
|
80
82
|
end
|
|
81
83
|
validate_dropout(options[:dropout])
|
|
82
84
|
validate_target_modules(options[:target_modules])
|
|
85
|
+
validate_train_on_prompt(options[:train_on_prompt])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def validate_train_on_prompt(train_on_prompt)
|
|
89
|
+
return if [true, false].include?(train_on_prompt)
|
|
90
|
+
|
|
91
|
+
raise ConfigurationError, "train_on_prompt must be true or false, got #{train_on_prompt.inspect}"
|
|
83
92
|
end
|
|
84
93
|
|
|
85
94
|
def validate_dropout(dropout)
|
|
@@ -102,12 +111,13 @@ module RPEFT
|
|
|
102
111
|
raise ConfigurationError, 'resume: true requires an explicit output_dir: to find the previous checkpoints'
|
|
103
112
|
end
|
|
104
113
|
|
|
105
|
-
#
|
|
114
|
+
# A chat transcript is rendered with the chat template and plain text is not, so a loss measured on one
|
|
115
|
+
# says little about a model trained on the other.
|
|
106
116
|
def coerce_eval_dataset(eval_dataset, training_dataset)
|
|
107
117
|
return nil unless eval_dataset
|
|
108
118
|
|
|
109
119
|
dataset = Dataset.coerce(eval_dataset)
|
|
110
|
-
return dataset if dataset.
|
|
120
|
+
return dataset if dataset.messages? == training_dataset.messages?
|
|
111
121
|
|
|
112
122
|
# Raised before train assigns the dataset, so its ensure clause cannot discard it.
|
|
113
123
|
dataset.discard unless dataset.equal?(eval_dataset)
|
|
@@ -133,6 +143,7 @@ module RPEFT
|
|
|
133
143
|
'epochs' => options[:epochs],
|
|
134
144
|
'batch_size' => options[:batch_size],
|
|
135
145
|
'learning_rate' => options[:learning_rate],
|
|
146
|
+
'train_on_prompt' => options[:train_on_prompt],
|
|
136
147
|
'logging_steps' => 5,
|
|
137
148
|
'seed' => 42,
|
|
138
149
|
'trust_remote_code' => false,
|
|
@@ -145,7 +156,10 @@ module RPEFT
|
|
|
145
156
|
'target_modules' => options[:target_modules]
|
|
146
157
|
}
|
|
147
158
|
}
|
|
148
|
-
|
|
159
|
+
if options[:eval_dataset]
|
|
160
|
+
payload['eval_dataset_path'] = options[:eval_dataset].path
|
|
161
|
+
payload['eval_dataset_format'] = options[:eval_dataset].backend_format
|
|
162
|
+
end
|
|
149
163
|
|
|
150
164
|
payload
|
|
151
165
|
end
|
|
@@ -192,6 +206,9 @@ module RPEFT
|
|
|
192
206
|
'batch_size' => options[:batch_size],
|
|
193
207
|
'learning_rate' => options[:learning_rate],
|
|
194
208
|
'max_length' => options[:max_length],
|
|
209
|
+
'train_on_prompt' => options[:train_on_prompt],
|
|
210
|
+
'skipped_examples' => result['skipped_examples'],
|
|
211
|
+
'eval_skipped_examples' => result['eval_skipped_examples'],
|
|
195
212
|
'device' => result['device'] || device.to_s,
|
|
196
213
|
'train_loss' => result['final_loss'],
|
|
197
214
|
'eval_loss' => result['eval_loss'],
|
data/lib/rpeft/version.rb
CHANGED
|
@@ -22,6 +22,40 @@ def render(tokenizer, messages, add_generation_prompt=False):
|
|
|
22
22
|
return rendered
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
def segments(tokenizer, messages):
|
|
26
|
+
"""Splits a rendered transcript into (text, trainable) pairs, trainable for the assistant's turns.
|
|
27
|
+
|
|
28
|
+
Each assistant turn is located by rendering the transcript up to its generation prompt and up to the
|
|
29
|
+
end of the turn: the difference is what the model writes at inference, the turn's end marker included.
|
|
30
|
+
"""
|
|
31
|
+
rendered = render(tokenizer, messages)
|
|
32
|
+
pairs = []
|
|
33
|
+
position = 0
|
|
34
|
+
for index, message in enumerate(messages):
|
|
35
|
+
# A turn opening the transcript answers nothing, such as a greeting, so it is read as context.
|
|
36
|
+
# Locating it would also mean rendering an empty transcript, which chat templates refuse.
|
|
37
|
+
if index == 0 or message.get("role") != "assistant":
|
|
38
|
+
continue
|
|
39
|
+
|
|
40
|
+
start = render(tokenizer, messages[:index], add_generation_prompt=True)
|
|
41
|
+
through = render(tokenizer, messages[: index + 1])
|
|
42
|
+
# Some templates render earlier turns differently once the conversation goes on, such as dropping
|
|
43
|
+
# reasoning from past replies. The turn then cannot be located, and guessing would train on the wrong text.
|
|
44
|
+
if not (rendered.startswith(through) and through.startswith(start) and len(start) >= position):
|
|
45
|
+
raise ValueError(
|
|
46
|
+
"the chat template renders earlier turns differently as the conversation goes on, so the "
|
|
47
|
+
"assistant's replies cannot be located; pass train_on_prompt: true (--train-on-prompt in the CLI) "
|
|
48
|
+
"to train on the whole transcript"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
pairs.append((rendered[position : len(start)], False))
|
|
52
|
+
pairs.append((rendered[len(start) : len(through)], True))
|
|
53
|
+
position = len(through)
|
|
54
|
+
|
|
55
|
+
pairs.append((rendered[position:], False))
|
|
56
|
+
return [(text, trainable) for text, trainable in pairs if text]
|
|
57
|
+
|
|
58
|
+
|
|
25
59
|
def writes_special_tokens(tokenizer):
|
|
26
60
|
"""Whether render's output already holds the special tokens, BOS included, that the model expects.
|
|
27
61
|
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import sys
|
|
2
3
|
|
|
3
4
|
from . import _chat
|
|
4
5
|
from ._errors import BackendError, stage
|
|
5
6
|
|
|
7
|
+
IGNORED = -100
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
# The record fields each backend format reads. text_field stands in for the configurable text column.
|
|
10
|
+
FIELDS = {"text": ("text_field",), "completion": ("prompt", "completion"), "messages": ("messages",)}
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_tokenized_dataset(
|
|
14
|
+
path, tokenizer, text_field, max_length, dataset_format="text", train_on_prompt=False, name="dataset"
|
|
15
|
+
):
|
|
16
|
+
"""Returns the tokenized dataset and the number of examples left out of it.
|
|
17
|
+
|
|
18
|
+
Every example carries its own labels: the prompt positions hold IGNORED unless train_on_prompt is set,
|
|
19
|
+
so the loss covers only what the model is meant to write. name tells the datasets apart in messages.
|
|
20
|
+
"""
|
|
8
21
|
with stage("dataset"):
|
|
9
22
|
from datasets import Dataset
|
|
10
23
|
|
|
11
24
|
# Built in memory rather than with load_dataset("json", ...), which writes an Arrow copy of every
|
|
12
25
|
# file into the Hugging Face cache. Each run reads a fresh file, so that copy would never be reused.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
raise BackendError(
|
|
17
|
-
"dataset",
|
|
18
|
-
"field '%s' not found in dataset (available: %s)"
|
|
19
|
-
% (field, ", ".join(dataset.column_names)),
|
|
20
|
-
)
|
|
26
|
+
records = _read_jsonl(path)
|
|
27
|
+
fields = [text_field if field == "text_field" else field for field in FIELDS[dataset_format]]
|
|
28
|
+
_check_fields(records, fields)
|
|
21
29
|
|
|
22
30
|
add_special_tokens = not (dataset_format == "messages" and _chat.writes_special_tokens(tokenizer))
|
|
23
31
|
# A chat template closes every turn with its own end marker. Plain text has none, so without an EOS
|
|
@@ -25,25 +33,95 @@ def load_tokenized_dataset(path, tokenizer, text_field, max_length, dataset_form
|
|
|
25
33
|
eos = tokenizer.eos_token_id if dataset_format != "messages" else None
|
|
26
34
|
content_length = max_length - 1 if eos is not None else max_length
|
|
27
35
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
examples = []
|
|
37
|
+
for record in records:
|
|
38
|
+
segments = _segments(tokenizer, record, dataset_format, fields, train_on_prompt)
|
|
39
|
+
example = _encode(tokenizer, segments, add_special_tokens, content_length, train_on_prompt, eos)
|
|
40
|
+
if example is not None:
|
|
41
|
+
examples.append(example)
|
|
33
42
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if eos is not None:
|
|
39
|
-
for input_ids, attention_mask in zip(encoded["input_ids"], encoded["attention_mask"]):
|
|
40
|
-
input_ids.append(eos)
|
|
41
|
-
attention_mask.append(1)
|
|
42
|
-
return encoded
|
|
43
|
-
|
|
44
|
-
return dataset.map(
|
|
45
|
-
tokenize, batched=True, remove_columns=dataset.column_names, keep_in_memory=True
|
|
43
|
+
skipped = len(records) - len(examples)
|
|
44
|
+
reason = (
|
|
45
|
+
"no answer left to train on: a transcript without an assistant reply after its first message, "
|
|
46
|
+
"or an answer cut off by max_length %d" % max_length
|
|
46
47
|
)
|
|
48
|
+
if not examples:
|
|
49
|
+
raise BackendError("dataset", "every example in %s has %s" % (name, reason))
|
|
50
|
+
if skipped:
|
|
51
|
+
print(
|
|
52
|
+
"rpeft: skipped %d of %d examples in %s with %s" % (skipped, len(records), name, reason),
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
return Dataset.from_list(examples), skipped
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _check_fields(records, fields):
|
|
60
|
+
available = set()
|
|
61
|
+
for record in records:
|
|
62
|
+
available.update(record)
|
|
63
|
+
for field in fields:
|
|
64
|
+
if field not in available:
|
|
65
|
+
raise BackendError(
|
|
66
|
+
"dataset",
|
|
67
|
+
"field '%s' not found in dataset (available: %s)" % (field, ", ".join(sorted(available))),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _segments(tokenizer, record, dataset_format, fields, train_on_prompt):
|
|
72
|
+
# (text, trainable) pairs whose concatenation is the whole example.
|
|
73
|
+
if dataset_format == "messages":
|
|
74
|
+
if train_on_prompt:
|
|
75
|
+
return [(_chat.render(tokenizer, record[fields[0]]), True)]
|
|
76
|
+
return _chat.segments(tokenizer, record[fields[0]])
|
|
77
|
+
if dataset_format == "completion":
|
|
78
|
+
return [(record[fields[0]], False), (record[fields[1]], True)]
|
|
79
|
+
return [(record[fields[0]], True)]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _encode(tokenizer, segments, add_special_tokens, content_length, train_on_prompt, eos):
|
|
83
|
+
def tokenize(text):
|
|
84
|
+
return tokenizer(
|
|
85
|
+
text, truncation=True, max_length=content_length, add_special_tokens=add_special_tokens
|
|
86
|
+
)["input_ids"]
|
|
87
|
+
|
|
88
|
+
# The example is tokenized as a whole, so every token is the one the model sees in the full text.
|
|
89
|
+
# Tokenizing segments one by one would not do: a SentencePiece tokenizer marks the start of each piece
|
|
90
|
+
# with a space, turning an answer "hello" into " hello". A segment ends where the text up to its end,
|
|
91
|
+
# tokenized on its own as the prompt is at inference, stops matching the whole.
|
|
92
|
+
text = "".join(segment for segment, _ in segments)
|
|
93
|
+
input_ids = tokenize(text)
|
|
94
|
+
labels = []
|
|
95
|
+
answers = []
|
|
96
|
+
start = 0
|
|
97
|
+
offset = 0
|
|
98
|
+
for segment, trainable in segments:
|
|
99
|
+
offset += len(segment)
|
|
100
|
+
end = len(input_ids) if offset == len(text) else max(start, _common_prefix(tokenize(text[:offset]), input_ids))
|
|
101
|
+
ids = input_ids[start:end]
|
|
102
|
+
labels.extend(ids if trainable or train_on_prompt else [IGNORED] * len(ids))
|
|
103
|
+
answers.extend([trainable] * len(ids))
|
|
104
|
+
start = end
|
|
105
|
+
|
|
106
|
+
if eos is not None:
|
|
107
|
+
input_ids.append(eos)
|
|
108
|
+
labels.append(eos)
|
|
109
|
+
|
|
110
|
+
# Without prompt training, an answer cut away entirely leaves an example that teaches nothing but to stop.
|
|
111
|
+
answer_cut_away = any(trainable and segment for segment, trainable in segments) and not any(answers)
|
|
112
|
+
if (not train_on_prompt and answer_cut_away) or all(label == IGNORED for label in labels):
|
|
113
|
+
return None
|
|
114
|
+
|
|
115
|
+
return {"input_ids": input_ids, "attention_mask": [1] * len(input_ids), "labels": labels}
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _common_prefix(ids, other):
|
|
119
|
+
length = 0
|
|
120
|
+
for left, right in zip(ids, other):
|
|
121
|
+
if left != right:
|
|
122
|
+
break
|
|
123
|
+
length += 1
|
|
124
|
+
return length
|
|
47
125
|
|
|
48
126
|
|
|
49
127
|
def _read_jsonl(path):
|
|
@@ -21,22 +21,11 @@ def train_lora(config):
|
|
|
21
21
|
device=device,
|
|
22
22
|
trust_remote_code=config.get("trust_remote_code", False),
|
|
23
23
|
)
|
|
24
|
-
dataset =
|
|
25
|
-
config["dataset_path"],
|
|
26
|
-
tokenizer,
|
|
27
|
-
text_field=config.get("text_field", "text"),
|
|
28
|
-
max_length=config.get("max_length", 512),
|
|
29
|
-
dataset_format=config.get("dataset_format", "text"),
|
|
30
|
-
)
|
|
24
|
+
dataset, skipped = _load_dataset(config, tokenizer, "dataset")
|
|
31
25
|
eval_dataset = None
|
|
26
|
+
eval_skipped = None
|
|
32
27
|
if config.get("eval_dataset_path"):
|
|
33
|
-
eval_dataset =
|
|
34
|
-
config["eval_dataset_path"],
|
|
35
|
-
tokenizer,
|
|
36
|
-
text_field=config.get("text_field", "text"),
|
|
37
|
-
max_length=config.get("max_length", 512),
|
|
38
|
-
dataset_format=config.get("dataset_format", "text"),
|
|
39
|
-
)
|
|
28
|
+
eval_dataset, eval_skipped = _load_dataset(config, tokenizer, "eval_dataset")
|
|
40
29
|
|
|
41
30
|
with stage("training"):
|
|
42
31
|
model, target_modules, param_counts = _apply_lora(model, lora)
|
|
@@ -54,7 +43,7 @@ def train_lora(config):
|
|
|
54
43
|
shutil.rmtree(_trainer_dir(output_dir), ignore_errors=True)
|
|
55
44
|
|
|
56
45
|
return _result(
|
|
57
|
-
config, device, dtype, target_modules, param_counts, metrics, steps, len(dataset)
|
|
46
|
+
config, device, dtype, target_modules, param_counts, metrics, steps, len(dataset), skipped, eval_skipped
|
|
58
47
|
)
|
|
59
48
|
finally:
|
|
60
49
|
model = None
|
|
@@ -62,6 +51,18 @@ def train_lora(config):
|
|
|
62
51
|
_device.empty_cache(device)
|
|
63
52
|
|
|
64
53
|
|
|
54
|
+
def _load_dataset(config, tokenizer, name):
|
|
55
|
+
return _data.load_tokenized_dataset(
|
|
56
|
+
config[name + "_path"],
|
|
57
|
+
tokenizer,
|
|
58
|
+
text_field=config.get("text_field", "text"),
|
|
59
|
+
max_length=config.get("max_length", 512),
|
|
60
|
+
dataset_format=config.get(name + "_format") or config.get("dataset_format", "text"),
|
|
61
|
+
train_on_prompt=bool(config.get("train_on_prompt", False)),
|
|
62
|
+
name=name,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
65
66
|
def _apply_lora(model, lora):
|
|
66
67
|
from peft import LoraConfig, TaskType, get_peft_model
|
|
67
68
|
|
|
@@ -117,7 +118,7 @@ def _build_trainer(model, tokenizer, dataset, eval_dataset, config, device, dtyp
|
|
|
117
118
|
)
|
|
118
119
|
|
|
119
120
|
|
|
120
|
-
def _result(config, device, dtype, target_modules, param_counts, metrics, steps, num_examples):
|
|
121
|
+
def _result(config, device, dtype, target_modules, param_counts, metrics, steps, num_examples, skipped, eval_skipped):
|
|
121
122
|
trainable_params, total_params = param_counts
|
|
122
123
|
effective = dict(config)
|
|
123
124
|
effective["device"] = device
|
|
@@ -133,6 +134,8 @@ def _result(config, device, dtype, target_modules, param_counts, metrics, steps,
|
|
|
133
134
|
"final_loss": float(metrics.get("train_loss", 0.0)),
|
|
134
135
|
"eval_loss": float(metrics["eval_loss"]) if "eval_loss" in metrics else None,
|
|
135
136
|
"num_examples": int(num_examples),
|
|
137
|
+
"skipped_examples": int(skipped),
|
|
138
|
+
"eval_skipped_examples": int(eval_skipped) if eval_skipped is not None else None,
|
|
136
139
|
"steps": int(steps),
|
|
137
140
|
"trainable_params": int(trainable_params),
|
|
138
141
|
"total_params": int(total_params),
|
|
@@ -156,15 +159,23 @@ def _resume_point(config):
|
|
|
156
159
|
|
|
157
160
|
|
|
158
161
|
def _collator(tokenizer):
|
|
159
|
-
#
|
|
160
|
-
# a pad token of their own pad with EOS (see _models.load_tokenizer),
|
|
161
|
-
# EOS too, so the EOS that ends each example would never be learned.
|
|
162
|
-
# added is ignored here, found through the attention mask instead of the token id.
|
|
162
|
+
# Labels come with each example rather than from the pad token id, as DataCollatorForLanguageModeling
|
|
163
|
+
# derives them. Tokenizers without a pad token of their own pad with EOS (see _models.load_tokenizer),
|
|
164
|
+
# and many that have one reuse EOS too, so the EOS that ends each example would never be learned.
|
|
163
165
|
def collate(features):
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
labels[
|
|
167
|
-
batch
|
|
166
|
+
import torch
|
|
167
|
+
|
|
168
|
+
labels = [list(feature["labels"]) for feature in features]
|
|
169
|
+
batch = tokenizer.pad(
|
|
170
|
+
[{key: value for key, value in feature.items() if key != "labels"} for feature in features],
|
|
171
|
+
return_tensors="pt",
|
|
172
|
+
)
|
|
173
|
+
width = batch["input_ids"].shape[1]
|
|
174
|
+
padded = []
|
|
175
|
+
for example in labels:
|
|
176
|
+
padding = [_data.IGNORED] * (width - len(example))
|
|
177
|
+
padded.append(padding + example if tokenizer.padding_side == "left" else example + padding)
|
|
178
|
+
batch["labels"] = torch.tensor(padded, dtype=torch.long)
|
|
168
179
|
return batch
|
|
169
180
|
|
|
170
181
|
return collate
|