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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74527e43eb7c5d06b33fa44b6bc80fc57d7af6b1039248b0dd889f64cd6a7a5b
4
- data.tar.gz: 497bff43821c0b8b25059de10321ac93d61ca3eacf3428d40e2de8db37b86b37
3
+ metadata.gz: d6162af3628a1e82e72c85f6f1ebf42c1f4bd58298992d4bc7036766628595c4
4
+ data.tar.gz: 52b2ac5be8272a31978e751e6d388e010cc97625b8cabeea4f6f6690bad3cb52
5
5
  SHA512:
6
- metadata.gz: 5b1d932dc735db4961c1c6532139c750f423e1ca83404bca9f7a7e041a680e42037be253ec75198bcd5cef501da1174d6673ef1c8ec4a3c0f28de8d8b85be78e
7
- data.tar.gz: 6d0bca19e47a92cbc975ee0fb73c06f5e4d337b3d6164371f43fcbd2ab7b1065cdd0b1a4730068e895312435c6f2d6bc91a64b62ff45f87a54fa16ca2d3fe32a
6
+ metadata.gz: ac75ae251f4f228ee5e3fca1b104f7819d509c10644754fb15754ade5bc82d03af67e3619c1492c9fbf715ca26f82e3e673caaf493f46114fd100271e28760bc
7
+ data.tar.gz: a3a06a0a30dd6419f92180c4ce3fa12b20f1a0a0ae45e503f0cd9b25afd23611f3bb1ed580f7e60899ab42c4d05ec79f9d317919e01b12aa00cc5aaa8f5c3e5f
data/README.md CHANGED
@@ -1,5 +1,8 @@
1
1
  # RPEFT
2
2
 
3
+ [![Gem Version](https://badge.fury.io/rb/rpeft.svg)](https://badge.fury.io/rb/rpeft)
4
+ [![CI](https://github.com/koic/rpeft/actions/workflows/main.yml/badge.svg)](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 $BA$ back into $W_0$.
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:` option](#training-options) of `RPEFT::LoRA.train` is exactly the $r$ above.
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 | Default | Meaning |
180
- | ----------------- | ------- | --------------------------------------------------------------------------------------- |
181
- | `rank:` | `8` | LoRA rank $r$, the rank of the low-rank update $BA$ |
182
- | `alpha:` | `16` | LoRA scaling factor (`lora_alpha` in PEFT) |
183
- | `dropout:` | `0.05` | LoRA dropout (`lora_dropout` in PEFT) |
184
- | `target_modules:` | `nil` | Modules to adapt, e.g. `%w(q_proj v_proj)`. `nil` uses PEFT's per-architecture defaults |
185
- | `epochs:` | `3` | Training epochs |
186
- | `batch_size:` | `4` | Per-device batch size |
187
- | `learning_rate:` | `2e-4` | Learning rate |
188
- | `max_length:` | `512` | Max token length per example |
189
- | `eval_dataset:` | `nil` | Held-out dataset, in the same format as `dataset:`. Enables per-epoch evaluation |
190
- | `checkpoints:` | `false` | Checkpoint every epoch so an interrupted run can resume; removed once training ends |
191
- | `resume:` | `false` | Resume from the last checkpoint in `output_dir`, which must be given explicitly |
192
- | `output_dir:` | `nil` | Adapter output directory: new, empty, or an earlier output. `nil` uses a temporary one |
193
- | `device:` | `:auto` | `:auto`, `:cuda`, `:mps`, or `:cpu` |
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.1.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
@@ -4,8 +4,9 @@ module RPEFT
4
4
  module CLI
5
5
  # @api private
6
6
  class LoRATrain < Command
7
- PASSTHROUGH_OPTIONS = %i(rank alpha dropout target_modules epochs batch_size
8
- learning_rate max_length device).freeze
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 text prompt, and `{"messages": [{"role": ..., "content": ...}, ...]}` is passed through
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 text here,
67
- # so only text and messages ever cross the boundary.
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 == :messages ? 'messages' : 'text'
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 a non-text dataset into text-format JSONL in
116
- # a temporary directory, so the backend only ever sees one format.
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
- out.puts JSON.generate('text' => render_instruction(record, lineno, source_path))
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
- text = "### Instruction:\n#{instruction}\n"
146
- text << "\n### Input:\n#{input}\n" if input && !input.to_s.empty?
147
- text << "\n### Response:\n#{output}"
148
- text
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
@@ -84,8 +84,8 @@ module RPEFT
84
84
  self
85
85
  end
86
86
 
87
- # Merges the learned B A update into the base model weights
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 delta W ~= B A of rank r:
10
+ # a low-rank update of rank r, scaled by alpha / r:
11
11
  #
12
- # W = W0 + B A
12
+ # W = W0 + delta W
13
+ # delta W ~= (alpha / r) B A
13
14
  #
14
- # The `rank:` option is exactly that r.
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
- # The backend tokenizes both datasets the same way, so mixing formats would silently mis-render the evaluation set.
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.backend_format == training_dataset.backend_format
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
- payload['eval_dataset_path'] = options[:eval_dataset].path if options[:eval_dataset]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RPEFT
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end
@@ -14,7 +14,7 @@ import math
14
14
  import os
15
15
  import sys
16
16
 
17
- __version__ = "0.1.0"
17
+ __version__ = "0.2.0"
18
18
 
19
19
 
20
20
  def _dumps(value):
@@ -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
- def load_tokenized_dataset(path, tokenizer, text_field, max_length, dataset_format="text"):
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
- dataset = Dataset.from_list(_read_jsonl(path))
14
- field = "messages" if dataset_format == "messages" else text_field
15
- if field not in dataset.column_names:
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
- def tokenize(batch):
29
- if dataset_format == "messages":
30
- texts = [_chat.render(tokenizer, messages) for messages in batch[field]]
31
- else:
32
- texts = batch[field]
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
- # No padding here: the collator pads each batch dynamically.
35
- encoded = tokenizer(
36
- texts, truncation=True, max_length=content_length, add_special_tokens=add_special_tokens
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 = _data.load_tokenized_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 = _data.load_tokenized_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
- # DataCollatorForLanguageModeling ignores every position holding the pad token. Tokenizers without
160
- # a pad token of their own pad with EOS (see _models.load_tokenizer), and many that have one reuse
161
- # EOS too, so the EOS that ends each example would never be learned. Only the padding the batch
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
- batch = tokenizer.pad(features, return_tensors="pt")
165
- labels = batch["input_ids"].clone()
166
- labels[batch["attention_mask"] == 0] = -100
167
- batch["labels"] = labels
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
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rpeft
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi ITO