rpeft 0.1.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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +364 -0
- data/exe/rpeft +8 -0
- data/lib/rpeft/arguments.rb +29 -0
- data/lib/rpeft/backend/error_translation.rb +28 -0
- data/lib/rpeft/backend/python.rb +161 -0
- data/lib/rpeft/backend.rb +24 -0
- data/lib/rpeft/cli/command.rb +78 -0
- data/lib/rpeft/cli/doctor.rb +75 -0
- data/lib/rpeft/cli/generate.rb +69 -0
- data/lib/rpeft/cli/lora_merge.rb +40 -0
- data/lib/rpeft/cli/lora_train.rb +76 -0
- data/lib/rpeft/cli.rb +77 -0
- data/lib/rpeft/dataset.rb +151 -0
- data/lib/rpeft/device.rb +17 -0
- data/lib/rpeft/diagnosis.rb +44 -0
- data/lib/rpeft/directory.rb +69 -0
- data/lib/rpeft/errors.rb +26 -0
- data/lib/rpeft/generation.rb +63 -0
- data/lib/rpeft/lora/adapter.rb +171 -0
- data/lib/rpeft/lora.rb +204 -0
- data/lib/rpeft/messages.rb +30 -0
- data/lib/rpeft/model.rb +70 -0
- data/lib/rpeft/version.rb +5 -0
- data/lib/rpeft.rb +37 -0
- data/python/rpeft_backend/__init__.py +90 -0
- data/python/rpeft_backend/_chat.py +79 -0
- data/python/rpeft_backend/_data.py +59 -0
- data/python/rpeft_backend/_device.py +57 -0
- data/python/rpeft_backend/_diagnostics.py +77 -0
- data/python/rpeft_backend/_errors.py +24 -0
- data/python/rpeft_backend/_generate.py +73 -0
- data/python/rpeft_backend/_merge.py +37 -0
- data/python/rpeft_backend/_models.py +30 -0
- data/python/rpeft_backend/_training.py +170 -0
- data/python/rpeft_backend/_versions.py +39 -0
- metadata +113 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 74527e43eb7c5d06b33fa44b6bc80fc57d7af6b1039248b0dd889f64cd6a7a5b
|
|
4
|
+
data.tar.gz: 497bff43821c0b8b25059de10321ac93d61ca3eacf3428d40e2de8db37b86b37
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5b1d932dc735db4961c1c6532139c750f423e1ca83404bca9f7a7e041a680e42037be253ec75198bcd5cef501da1174d6673ef1c8ec4a3c0f28de8d8b85be78e
|
|
7
|
+
data.tar.gz: 6d0bca19e47a92cbc975ee0fb73c06f5e4d337b3d6164371f43fcbd2ab7b1065cdd0b1a4730068e895312435c6f2d6bc91a64b62ff45f87a54fa16ca2d3fe32a
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Koichi ITO
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
# RPEFT
|
|
2
|
+
|
|
3
|
+
RPEFT brings LoRA fine-tuning of large language models to Ruby. It provides an API and CLI that model the LoRA lifecycle,
|
|
4
|
+
while the heavy lifting is done by the Python ecosystem (PyTorch, Hugging Face Transformers, PEFT, Datasets, Accelerate) through PyCall.
|
|
5
|
+
It is an independent project, not affiliated with Hugging Face.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- LoRA fine-tuning from JSONL files or Ruby Enumerables, in plain text, instruction, and chat formats
|
|
10
|
+
- Merging an adapter into its base model to produce a standalone model
|
|
11
|
+
- Generation and chat with a merged model, a Hugging Face model id, or an adapter on top of its base model
|
|
12
|
+
- Apple Silicon (MPS) as a first-class target, with CUDA and CPU auto-detected
|
|
13
|
+
- Validation loss, per-epoch checkpoints, and resuming
|
|
14
|
+
- A CLI for training, merging, and generation, and `rpeft doctor` to check the Python environment
|
|
15
|
+
|
|
16
|
+
## The model behind the API
|
|
17
|
+
|
|
18
|
+
LoRA keeps the base model weights $W_0$ frozen and learns a low-rank update of rank $r$:
|
|
19
|
+
|
|
20
|
+
```math
|
|
21
|
+
\begin{aligned}
|
|
22
|
+
W &= W_0 + \Delta W \\
|
|
23
|
+
\Delta W &\approx BA
|
|
24
|
+
\end{aligned}
|
|
25
|
+
```
|
|
26
|
+
|
|
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$.
|
|
28
|
+
The API is the same shape:
|
|
29
|
+
|
|
30
|
+
```mermaid
|
|
31
|
+
flowchart LR
|
|
32
|
+
base[Base Model] -- train --> adapter[LoRA Adapter] -. merge .-> merged[Merged Model]
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Merging is optional: an adapter can also generate on top of its base model as is.
|
|
36
|
+
|
|
37
|
+
The [`rank:` option](#training-options) of `RPEFT::LoRA.train` is exactly the $r$ above.
|
|
38
|
+
|
|
39
|
+
## Requirements
|
|
40
|
+
|
|
41
|
+
- Ruby 3.0+
|
|
42
|
+
- Python 3.10+ with a shared libpython (static Python builds such as pyenv's default do not work; tested with Python 3.12)
|
|
43
|
+
- Ruby and Python built for the same CPU architecture. On Apple Silicon, an x86_64 Ruby running under Rosetta cannot load an arm64 libpython;
|
|
44
|
+
a symbol error such as `_Py_NoneStruct` from `rpeft doctor` usually means this mismatch
|
|
45
|
+
- The Python packages `torch` (2.6 or later), `transformers` (4.56 or later), `peft`, `datasets`, and `accelerate`
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
Add the gem to your Gemfile:
|
|
50
|
+
|
|
51
|
+
```console
|
|
52
|
+
$ bundle add rpeft
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Set up a Python environment. With venv:
|
|
56
|
+
|
|
57
|
+
```console
|
|
58
|
+
$ python3 -m venv .venv
|
|
59
|
+
$ .venv/bin/pip install torch transformers peft datasets accelerate
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or with uv:
|
|
63
|
+
|
|
64
|
+
```console
|
|
65
|
+
$ uv venv
|
|
66
|
+
$ uv pip install torch transformers peft datasets accelerate
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Note for pyenv users: PyCall needs a shared libpython, so install the interpreter with:
|
|
70
|
+
|
|
71
|
+
```console
|
|
72
|
+
$ PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.12
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
When a `.venv` directory sits next to the Gemfile, RPEFT uses it automatically under `bundle exec` or `bundler/setup`. Set the `PYTHON` environment variable to override the interpreter,
|
|
76
|
+
then check the environment:
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
$ bundle exec rpeft doctor
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`rpeft doctor` reports the Python interpreter, each required package, and the available devices, and tells you what is missing or outdated.
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```ruby
|
|
87
|
+
require 'rpeft'
|
|
88
|
+
|
|
89
|
+
adapter = RPEFT::LoRA.train(
|
|
90
|
+
model: 'Qwen/Qwen2.5-Coder-0.5B',
|
|
91
|
+
dataset: 'ruby_answers.jsonl',
|
|
92
|
+
rank: 8, # The LoRA rank r
|
|
93
|
+
alpha: 16,
|
|
94
|
+
dropout: 0.05,
|
|
95
|
+
epochs: 2
|
|
96
|
+
)
|
|
97
|
+
adapter.save('./ruby-lora')
|
|
98
|
+
|
|
99
|
+
model = adapter.merge # W = W0 + B A
|
|
100
|
+
model.save('./ruby-model')
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
With these settings, training updates about 540 thousand of the model's 494 million parameters (0.11%).
|
|
104
|
+
|
|
105
|
+
Passing `eval_dataset:` holds a second dataset out of training and evaluates against it after every epoch.
|
|
106
|
+
The final validation loss lands in the adapter metadata, which is how you tell learning apart from memorizing:
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
adapter = RPEFT::LoRA.train(model: ..., dataset: 'train.jsonl', eval_dataset: 'validation.jsonl')
|
|
110
|
+
adapter.metadata.dig('training', 'train_loss') # => 0.74
|
|
111
|
+
adapter.metadata.dig('training', 'eval_loss') # => 0.91
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`RPEFT::LoRA.train` returns a `RPEFT::LoRA::Adapter` that knows its `path`, `base_model`, `rank`, `alpha`, `dropout`, `target_modules`, and training `metadata`.
|
|
115
|
+
`adapter.merge` returns a `RPEFT::Model` with `path` and `base_model`. `save` moves the artifact to the given path; a destination produced by a previous `save` is replaced,
|
|
116
|
+
so scripts can be re-run, while any other existing path is refused.
|
|
117
|
+
|
|
118
|
+
### Generation
|
|
119
|
+
|
|
120
|
+
`RPEFT::Model#generate` produces a completion for a prompt. It uses greedy decoding, so the output is deterministic for a given model and prompt.
|
|
121
|
+
The model can be a merged model directory or a Hugging Face model id, which makes a before and after comparison one call each:
|
|
122
|
+
|
|
123
|
+
```ruby
|
|
124
|
+
prompt = "User: How do I find the minimum value in an array?\nAssistant:"
|
|
125
|
+
|
|
126
|
+
base = RPEFT::Model.new(path: 'Qwen/Qwen2.5-Coder-0.5B')
|
|
127
|
+
base.generate(prompt, max_new_tokens: 64) # A step-by-step explanation from the base model
|
|
128
|
+
|
|
129
|
+
model = adapter.merge
|
|
130
|
+
model.generate(prompt, max_new_tokens: 64) # Ruby code from the fine-tuned model
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
A model name is looked up as a local directory first, as Transformers does, so run RPEFT only in directories you trust.
|
|
134
|
+
|
|
135
|
+
Passing `temperature:` or `top_p:` switches decoding from greedy to sampling, which makes the model produce
|
|
136
|
+
different wording on each call. `seed:` makes sampled output reproducible again:
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
model.generate(prompt, temperature: 0.7, top_p: 0.9, seed: 42)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`RPEFT::LoRA::Adapter#generate` runs the same generation with the adapter applied on top of its base model, without merging.
|
|
143
|
+
Since an adapter is a few MB against roughly a gigabyte for a merged model, this is the cheap way to try an adapter,
|
|
144
|
+
including one received from someone else:
|
|
145
|
+
|
|
146
|
+
```ruby
|
|
147
|
+
adapter = RPEFT::LoRA::Adapter.load('./ruby-lora')
|
|
148
|
+
adapter.generate(prompt, max_new_tokens: 64) # the same output, no merged model needed
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
The base model is read from the metadata recorded in the adapter directory, so it does not have to be given again.
|
|
152
|
+
When the base model is a local directory, its absolute path is recorded there and in `adapter_config.json`,
|
|
153
|
+
since a relative path would point elsewhere once the adapter is loaded from another directory.
|
|
154
|
+
Check that path before sharing such an adapter; `Adapter.load` takes `base_model:` to load it against a model somewhere else.
|
|
155
|
+
|
|
156
|
+
`RPEFT::Model#chat` takes a transcript instead of a prompt and renders it with the model's own chat template, including the marker
|
|
157
|
+
that tells the model where its turn begins. Pass the whole transcript on every call to continue a conversation; roles are limited to `system`, `user`, and `assistant`:
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
chat_model = RPEFT::Model.new(path: 'Qwen/Qwen2.5-Coder-0.5B-Instruct')
|
|
161
|
+
|
|
162
|
+
chat_model.chat([
|
|
163
|
+
{role: 'user', content: 'How do I reverse an array in Ruby?'},
|
|
164
|
+
{role: 'assistant', content: '[1, 2, 3].reverse'},
|
|
165
|
+
{role: 'user', content: 'And the last element?'}
|
|
166
|
+
])
|
|
167
|
+
# => "The last element of the array is `3`."
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`RPEFT::LoRA::Adapter#chat` does the same with the adapter applied on top of its base model.
|
|
171
|
+
|
|
172
|
+
Reach for `#chat` when the model understands a chat template: an instruction-tuned model, or one fine-tuned on chat-format data.
|
|
173
|
+
A base model is a different matter. `Qwen/Qwen2.5-Coder-0.5B`, for instance, ships a chat template but was never instruction-tuned,
|
|
174
|
+
so prompting it in chat form produces poor output while `#generate` with a plain prompt works well.
|
|
175
|
+
The rule behind this also applies to choosing a dataset format: the prompt at inference should look like the text the model was trained on.
|
|
176
|
+
|
|
177
|
+
### Training options
|
|
178
|
+
|
|
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` |
|
|
194
|
+
|
|
195
|
+
### Dataset
|
|
196
|
+
|
|
197
|
+
Each line of a JSONL dataset is a JSON object in one of three shapes. The simplest has a `text` key:
|
|
198
|
+
|
|
199
|
+
```json
|
|
200
|
+
{"text":"User: How do I reverse an array?\nAssistant: [1, 2, 3].reverse\n# => [3, 2, 1]"}
|
|
201
|
+
{"text":"User: How do I get the length of a string?\nAssistant: 'hello'.length\n# => 5"}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Lines with `instruction`/`output` keys (and an optional `input`) are also accepted and rendered into a text prompt automatically:
|
|
205
|
+
|
|
206
|
+
```json
|
|
207
|
+
{"instruction":"Explain what Array#map does","output":"It returns a new array with the block applied to each element."}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Chat transcripts are accepted as a `messages` array. These are rendered with the tokenizer's own chat template,
|
|
211
|
+
so the conversation is formatted the way the model expects. Roles are limited to `system`, `user`, and `assistant`,
|
|
212
|
+
the ones every chat template understands:
|
|
213
|
+
|
|
214
|
+
```json
|
|
215
|
+
{"messages":[{"role":"user","content":"How do I reverse an array?"},{"role":"assistant","content":"[1, 2, 3].reverse"}]}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
`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
|
+
|
|
220
|
+
```ruby
|
|
221
|
+
RPEFT::LoRA.train(
|
|
222
|
+
model: 'Qwen/Qwen2.5-Coder-0.5B',
|
|
223
|
+
dataset: Article.published.map { |article| {text: article.body} }
|
|
224
|
+
)
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
A useful fine-tuning dataset teaches the model something it does not already do. The `examples/ruby_answers.jsonl` dataset in the repository teaches the model to answer programming questions with Ruby code only:
|
|
228
|
+
the base model replies with an explanation, often followed by Python code, while the fine-tuned model replies with a bare snippet and a `# =>` comment. The effect is visible at a glance in any spoken language,
|
|
229
|
+
because the difference is the shape of the output rather than its wording.
|
|
230
|
+
|
|
231
|
+
### Devices and Apple Silicon
|
|
232
|
+
|
|
233
|
+
The device is auto-detected with CUDA > MPS > CPU priority:
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
RPEFT.device # => :mps
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Apple Silicon Macs (M1 and later) are a first-class target: small models such as `Qwen/Qwen2.5-Coder-0.5B` fine-tune on MPS.
|
|
240
|
+
Training uses float32 on MPS (half precision there is numerically fragile), bfloat16 on capable CUDA GPUs, and `PYTORCH_ENABLE_MPS_FALLBACK=1` is set automatically for
|
|
241
|
+
operators MPS does not implement. Merging always runs on CPU, which is the memory-safest place for it.
|
|
242
|
+
|
|
243
|
+
### Environment check
|
|
244
|
+
|
|
245
|
+
```ruby
|
|
246
|
+
RPEFT.check # => a Hash describing Python, packages, and devices
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
```console
|
|
250
|
+
$ rpeft doctor
|
|
251
|
+
Ruby: 4.0.6 (rpeft 0.1.0)
|
|
252
|
+
Python: 3.12.13 (/path/to/.venv/bin/python)
|
|
253
|
+
torch: OK 2.13.0
|
|
254
|
+
transformers: OK 5.15.0
|
|
255
|
+
peft: OK 0.20.0
|
|
256
|
+
datasets: OK 5.0.1
|
|
257
|
+
accelerate: OK 1.14.0
|
|
258
|
+
Device: mps (CUDA: no, MPS: yes)
|
|
259
|
+
Status: OK
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### Errors
|
|
263
|
+
|
|
264
|
+
Python exceptions never surface raw. They are translated into `RPEFT::DependencyError`, `RPEFT::ModelLoadError`, `RPEFT::DatasetError`, `RPEFT::TrainingError`,
|
|
265
|
+
`RPEFT::MergeError`, or `RPEFT::GenerationError`, and the original Python error (with its traceback) stays reachable via `error.cause`.
|
|
266
|
+
Invalid arguments are rejected in Ruby with `RPEFT::ConfigurationError` before the backend is called.
|
|
267
|
+
|
|
268
|
+
## CLI
|
|
269
|
+
|
|
270
|
+
The CLI covers the core of the lifecycle: training, merging, and generation from a prompt.
|
|
271
|
+
Validation datasets, checkpoints, and chat are available from the Ruby API only.
|
|
272
|
+
|
|
273
|
+
```console
|
|
274
|
+
$ rpeft lora train \
|
|
275
|
+
--model Qwen/Qwen2.5-Coder-0.5B \
|
|
276
|
+
--dataset ruby_answers.jsonl \
|
|
277
|
+
--rank 8 \
|
|
278
|
+
--epochs 2 \
|
|
279
|
+
--output ruby-lora
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
```console
|
|
283
|
+
$ rpeft lora merge \
|
|
284
|
+
--adapter ruby-lora \
|
|
285
|
+
--output ruby-model
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`lora merge` reads the base model from the metadata recorded in the adapter directory; pass `--model` to override it (for example, for adapters produced by plain PEFT).
|
|
289
|
+
|
|
290
|
+
```console
|
|
291
|
+
$ rpeft generate \
|
|
292
|
+
--model ruby-model \
|
|
293
|
+
--prompt $'User: How do I find the minimum value in an array?\nAssistant:'
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
`--model` also accepts a Hugging Face model id, so the same command shows the base model's output for comparison.
|
|
297
|
+
`--temperature`, `--top-p`, and `--seed` are the CLI equivalents of the sampling options above.
|
|
298
|
+
Passing `--adapter` instead generates with the adapter applied on top of its base model, without merging:
|
|
299
|
+
|
|
300
|
+
```console
|
|
301
|
+
$ rpeft generate \
|
|
302
|
+
--adapter ruby-lora \
|
|
303
|
+
--prompt $'User: How do I find the minimum value in an array?\nAssistant:'
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
`rpeft doctor` and `rpeft version` are also available.
|
|
307
|
+
|
|
308
|
+
## Examples
|
|
309
|
+
|
|
310
|
+
A runnable end-to-end example, which trains an adapter, merges it, and compares the model's answers before and after,
|
|
311
|
+
is available in [`examples`](https://github.com/koic/rpeft/tree/master/examples).
|
|
312
|
+
|
|
313
|
+
## Architecture
|
|
314
|
+
|
|
315
|
+
The Ruby side is a thin layer. Each call goes through PyCall to the RPEFT backend, a Python package bundled with the gem and not a public API,
|
|
316
|
+
which drives the Python libraries that do the actual work on the selected device:
|
|
317
|
+
|
|
318
|
+
```mermaid
|
|
319
|
+
flowchart LR
|
|
320
|
+
subgraph rubyside [Ruby]
|
|
321
|
+
ruby[Ruby application] --> rpeft[RPEFT] --> pycall[PyCall]
|
|
322
|
+
cli[RPEFT CLI] --> rpeft
|
|
323
|
+
end
|
|
324
|
+
subgraph pythonside [Python]
|
|
325
|
+
backend[RPEFT backend]
|
|
326
|
+
subgraph hf [Model libraries]
|
|
327
|
+
direction TB
|
|
328
|
+
peft[PEFT] --> transformers[Transformers] --> accelerate[Accelerate]
|
|
329
|
+
end
|
|
330
|
+
backend --> datasets[Datasets] & hf & torch[PyTorch]
|
|
331
|
+
hf --> torch
|
|
332
|
+
end
|
|
333
|
+
subgraph devices [Device]
|
|
334
|
+
device[CUDA / MPS / CPU]
|
|
335
|
+
end
|
|
336
|
+
pycall --> backend
|
|
337
|
+
torch --> device
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
RPEFT CLI, RPEFT, and the RPEFT backend ship in the rpeft gem.
|
|
341
|
+
Arrows show what each part uses at run time, not package dependencies.
|
|
342
|
+
|
|
343
|
+
RPEFT does not reimplement Transformers or LoRA in Ruby, and it does not hide what fine-tuning is. It hides Python plumbing, not concepts.
|
|
344
|
+
|
|
345
|
+
For the same reason, adapters are saved and loaded by PEFT itself. An adapter directory is a standard PEFT adapter (`adapter_config.json` and `adapter_model.safetensors`) with
|
|
346
|
+
RPEFT's own metadata in `rpeft_metadata.json` alongside, so adapters trained with RPEFT load in Python with PEFT's `PeftModel.from_pretrained`, and `RPEFT::LoRA::Adapter.load` accepts
|
|
347
|
+
adapters produced by plain PEFT.
|
|
348
|
+
|
|
349
|
+
## Process model and limitations
|
|
350
|
+
|
|
351
|
+
RPEFT is designed for CLI, Rake tasks, batch jobs, and offline experiments. It is not designed to run inside web application workers:
|
|
352
|
+
|
|
353
|
+
- PyCall embeds a single Python interpreter in the Ruby process. Backend calls hold both the Python GIL and Ruby's GVL, so every Ruby thread in the process stalls until the call returns.
|
|
354
|
+
- Do not fork the process after the backend has been initialized (Spring, Puma workers, and so on). The backend loads lazily, so forking before the first call is safe.
|
|
355
|
+
- Training keeps no checkpoints unless `checkpoints: true` is passed, so by default a crash during training loses that run's progress.
|
|
356
|
+
|
|
357
|
+
## Name
|
|
358
|
+
|
|
359
|
+
RPEFT stands for Ruby and PEFT, Parameter-Efficient Fine-Tuning: training a small number of added parameters
|
|
360
|
+
instead of the whole model. Of the PEFT methods, RPEFT covers LoRA, through Hugging Face's PEFT library.
|
|
361
|
+
|
|
362
|
+
## License
|
|
363
|
+
|
|
364
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/exe/rpeft
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pathname'
|
|
4
|
+
|
|
5
|
+
module RPEFT
|
|
6
|
+
# @api private
|
|
7
|
+
#
|
|
8
|
+
# Argument checks shared by the public API, so that a wrong argument raises ConfigurationError
|
|
9
|
+
# in Ruby instead of an arbitrary error from deep inside Ruby or the backend.
|
|
10
|
+
module Arguments
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# A local path or a Hugging Face Hub id, given as a String or a Pathname.
|
|
14
|
+
def path(value, name)
|
|
15
|
+
path = value.is_a?(Pathname) ? value.to_s : value
|
|
16
|
+
return path if path.is_a?(String) && !path.empty?
|
|
17
|
+
|
|
18
|
+
raise ConfigurationError, "#{name} must be a non-empty String or Pathname, got #{value.inspect}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# A base model: a Hub id is kept verbatim, while a local directory is made absolute. The model is
|
|
22
|
+
# recorded in the adapter and loaded again later, possibly from another directory, where a relative
|
|
23
|
+
# path would name something else.
|
|
24
|
+
def model(value, name)
|
|
25
|
+
model = path(value, name)
|
|
26
|
+
File.directory?(model) ? File.expand_path(model) : model
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
module Backend
|
|
5
|
+
# Maps the "[stage:xxx]" token that the bundled Python backend prefixes to its error messages
|
|
6
|
+
# onto the Ruby exception hierarchy.
|
|
7
|
+
# Parsing arbitrary Python exception class names would be brittle across library versions;
|
|
8
|
+
# parsing a sentinel we emit ourselves is deterministic.
|
|
9
|
+
module ErrorTranslation
|
|
10
|
+
STAGE_MAP = {
|
|
11
|
+
'dependency' => DependencyError,
|
|
12
|
+
'device' => DependencyError,
|
|
13
|
+
'model_load' => ModelLoadError,
|
|
14
|
+
'dataset' => DatasetError,
|
|
15
|
+
'training' => TrainingError,
|
|
16
|
+
'adapter_save' => TrainingError,
|
|
17
|
+
'merge' => MergeError,
|
|
18
|
+
'generation' => GenerationError
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
def self.translate(error, default:)
|
|
22
|
+
stage = error.message[/\[stage:(\w+)\]/, 1]
|
|
23
|
+
error_class = STAGE_MAP.fetch(stage, default)
|
|
24
|
+
error_class.new(error.message.sub(/\[stage:\w+\]\s*/, ''))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module RPEFT
|
|
6
|
+
module Backend
|
|
7
|
+
# Default backend. Bridges to the bundled python/rpeft_backend package through PyCall.
|
|
8
|
+
# Only JSON strings cross the PyCall boundary, so no Python object ever leaks into the Ruby API.
|
|
9
|
+
#
|
|
10
|
+
# PyCall and the Python interpreter are loaded lazily on the first call: `require "rpeft"`
|
|
11
|
+
# must keep working without a Python environment.
|
|
12
|
+
class Python
|
|
13
|
+
PYTHON_DIR = File.expand_path('../../../python', __dir__)
|
|
14
|
+
|
|
15
|
+
# The interpreter to use: the PYTHON environment variable when set, otherwise .venv/bin/python next to
|
|
16
|
+
# the Gemfile of the running bundle, otherwise nil (PyCall falls back to python3 on PATH).
|
|
17
|
+
# The bundle root rather than the current directory, so the choice does not depend on where a command
|
|
18
|
+
# happens to be run.
|
|
19
|
+
def self.default_python
|
|
20
|
+
python = ENV.fetch('PYTHON', nil)
|
|
21
|
+
return python if python && !python.empty?
|
|
22
|
+
|
|
23
|
+
gemfile = ENV.fetch('BUNDLE_GEMFILE', nil)
|
|
24
|
+
return nil if gemfile.nil? || gemfile.empty?
|
|
25
|
+
|
|
26
|
+
bundle_venv = File.join(File.dirname(File.expand_path(gemfile)), '.venv', 'bin', 'python')
|
|
27
|
+
|
|
28
|
+
File.executable?(bundle_venv) ? bundle_venv : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# When PYTHON points into a venv, returns its site-packages directory; nil otherwise.
|
|
32
|
+
# Embedded CPython reports the Ruby binary as sys.executable, so the interpreter never detects
|
|
33
|
+
# pyvenv.cfg on its own and the venv packages would be invisible without this.
|
|
34
|
+
def self.venv_site_packages(python_path)
|
|
35
|
+
return nil unless python_path
|
|
36
|
+
|
|
37
|
+
venv_root = File.dirname(File.dirname(File.expand_path(python_path)))
|
|
38
|
+
return nil unless File.file?(File.join(venv_root, 'pyvenv.cfg'))
|
|
39
|
+
|
|
40
|
+
Dir[File.join(venv_root, 'lib', 'python*', 'site-packages')].first
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# PyCall never finalizes the embedded interpreter, so without this Python's atexit handlers never run.
|
|
44
|
+
# multiprocessing relies on them to unregister its semaphores (tqdm creates one for its progress bars),
|
|
45
|
+
# and resource_tracker would otherwise warn about leaked semaphores when the process exits.
|
|
46
|
+
def self.run_exit_handlers(atexit)
|
|
47
|
+
atexit._run_exitfuncs
|
|
48
|
+
rescue StandardError
|
|
49
|
+
# The Ruby process is already exiting; a failing Python handler must not turn that into an error.
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def initialize
|
|
54
|
+
@mutex = Mutex.new
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def train_lora(config)
|
|
58
|
+
call(:train_lora, config, default_error: TrainingError)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def merge_lora(config)
|
|
62
|
+
call(:merge_lora, config, default_error: MergeError)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def generate(config)
|
|
66
|
+
call(:generate, config, default_error: GenerationError)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def detect_device(config = {})
|
|
70
|
+
call(:detect_device, config, default_error: DependencyError)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def diagnostics
|
|
74
|
+
call(:diagnostics, nil, default_error: DependencyError)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def call(name, config, default_error:)
|
|
80
|
+
facade = backend_module
|
|
81
|
+
result = if config
|
|
82
|
+
facade.public_send(name, JSON.generate(config))
|
|
83
|
+
else
|
|
84
|
+
facade.public_send(name)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
JSON.parse(result.to_s)
|
|
88
|
+
rescue Error
|
|
89
|
+
raise
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
# Raising inside the rescue sets #cause to the original error,
|
|
92
|
+
# so the Python traceback stays reachable for debugging.
|
|
93
|
+
raise ErrorTranslation.translate(e, default: default_error)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def backend_module
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@backend_module ||= import_backend
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def import_backend
|
|
103
|
+
# Both must be exported before the embedded Python imports torch/tokenizers,
|
|
104
|
+
# so they are set here rather than in Python.
|
|
105
|
+
ENV['TOKENIZERS_PARALLELISM'] ||= 'false'
|
|
106
|
+
ENV['PYTORCH_ENABLE_MPS_FALLBACK'] ||= '1'
|
|
107
|
+
python = self.class.default_python
|
|
108
|
+
ENV['PYTHON'] = python if python
|
|
109
|
+
|
|
110
|
+
configure_venv
|
|
111
|
+
|
|
112
|
+
require_pycall
|
|
113
|
+
|
|
114
|
+
import_backend_module
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# PYTHONPATH is read at interpreter initialization, so this must run
|
|
118
|
+
# before PyCall first touches Python.
|
|
119
|
+
def configure_venv
|
|
120
|
+
site_packages = self.class.venv_site_packages(ENV.fetch('PYTHON', nil))
|
|
121
|
+
return unless site_packages
|
|
122
|
+
|
|
123
|
+
entries = ENV['PYTHONPATH'].to_s.split(File::PATH_SEPARATOR)
|
|
124
|
+
return if entries.include?(site_packages)
|
|
125
|
+
|
|
126
|
+
ENV['PYTHONPATH'] = [site_packages, *entries].join(File::PATH_SEPARATOR)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def require_pycall
|
|
130
|
+
require 'pycall'
|
|
131
|
+
rescue LoadError
|
|
132
|
+
raise DependencyError, 'PyCall is not installed. Add `gem "pycall"` to your Gemfile and run `bundle install`.'
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Referencing PyCall constants is only safe once require_pycall has succeeded,
|
|
136
|
+
# hence the separate method.
|
|
137
|
+
def import_backend_module
|
|
138
|
+
# Unconditional insert: this runs once per process (memoized in backend_module),
|
|
139
|
+
# and checking membership via sys.path.to_a triggers PyCall list-conversion warnings.
|
|
140
|
+
sys = PyCall.import_module('sys')
|
|
141
|
+
sys.path.insert(0, PYTHON_DIR)
|
|
142
|
+
|
|
143
|
+
backend = PyCall.import_module('rpeft_backend')
|
|
144
|
+
install_exit_hook(PyCall.import_module('atexit'))
|
|
145
|
+
backend
|
|
146
|
+
rescue PyCall::PyError => e
|
|
147
|
+
raise DependencyError, "failed to import the RPEFT Python backend: #{e.message}"
|
|
148
|
+
rescue PyCall::Error => e
|
|
149
|
+
raise DependencyError, "Python interpreter not found or PyCall failed to initialize (#{e.message}). " \
|
|
150
|
+
'PyCall needs a Python with a shared libpython built for the same CPU ' \
|
|
151
|
+
"architecture as Ruby; static builds (pyenv's default) and an x86_64 Ruby " \
|
|
152
|
+
'under Rosetta with an arm64 Python do not work. Set the PYTHON environment ' \
|
|
153
|
+
'variable to a compatible interpreter, e.g. PYTHON=$(command -v python3.12).'
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def install_exit_hook(atexit)
|
|
157
|
+
at_exit { self.class.run_exit_handlers(atexit) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'backend/error_translation'
|
|
4
|
+
|
|
5
|
+
module RPEFT
|
|
6
|
+
# Seam between the Ruby API and the Python backend.
|
|
7
|
+
#
|
|
8
|
+
# A backend implements four methods, each taking and returning plain Hashes with String keys:
|
|
9
|
+
#
|
|
10
|
+
# train_lora(config) -> Hash
|
|
11
|
+
# merge_lora(config) -> Hash
|
|
12
|
+
# generate(config) -> Hash
|
|
13
|
+
# detect_device(config) -> Hash
|
|
14
|
+
# diagnostics -> Hash
|
|
15
|
+
#
|
|
16
|
+
# Backend::Python is the default implementation. Tests inject a fake via RPEFT.backend=.
|
|
17
|
+
module Backend
|
|
18
|
+
def self.default
|
|
19
|
+
require_relative 'backend/python'
|
|
20
|
+
|
|
21
|
+
Python.new
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|