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
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
|
|
5
|
+
module RPEFT
|
|
6
|
+
# @api private
|
|
7
|
+
module Directory
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Moves source to dest. An existing destination is replaced only when it contains one of the marker files,
|
|
11
|
+
# i.e. when it looks like a previously saved artifact of the same kind.
|
|
12
|
+
# Anything else is refused so that a mistyped path never deletes unrelated data.
|
|
13
|
+
def move(source, dest, replace_markers: [])
|
|
14
|
+
# A Model made from a Hugging Face Hub id has no local directory to move.
|
|
15
|
+
raise Error, "nothing to save: #{source} is not a local directory" unless File.directory?(source)
|
|
16
|
+
|
|
17
|
+
dest = File.expand_path(dest)
|
|
18
|
+
return dest if canonical(source) == canonical(dest)
|
|
19
|
+
if inside?(source, dest) || inside?(dest, source)
|
|
20
|
+
raise ConfigurationError, "cannot save #{File.expand_path(source)} to #{dest}: one directory contains the other"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
if File.exist?(dest)
|
|
24
|
+
unless replaceable?(dest, replace_markers)
|
|
25
|
+
raise ConfigurationError, "destination already exists: #{dest} (RPEFT only replaces " \
|
|
26
|
+
'directories it previously saved; remove it or pick another path)'
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
FileUtils.rm_rf(dest)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
33
|
+
FileUtils.mv(source, dest)
|
|
34
|
+
|
|
35
|
+
dest
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Makes dir ready to be written into, under the same rule as move: it must be missing, empty,
|
|
39
|
+
# or an artifact RPEFT saved there before.
|
|
40
|
+
def prepare(dir, markers:)
|
|
41
|
+
dir = File.expand_path(dir)
|
|
42
|
+
if File.exist?(dir) && !(File.directory?(dir) && (Dir.empty?(dir) || replaceable?(dir, markers)))
|
|
43
|
+
raise ConfigurationError, "output directory is not empty: #{dir} (RPEFT only writes into empty directories " \
|
|
44
|
+
'or ones it previously wrote; remove it or pick another path)'
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
FileUtils.mkdir_p(dir)
|
|
48
|
+
dir
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def replaceable?(dest, markers)
|
|
52
|
+
markers.any? { |marker| File.exist?(File.join(dest, marker)) }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Resolves symlinks in the part of the path that exists, so that two spellings of the same location
|
|
56
|
+
# (such as /var and /private/var on macOS) compare equal even when the last components do not exist yet.
|
|
57
|
+
def canonical(path)
|
|
58
|
+
path = File.expand_path(path)
|
|
59
|
+
return File.realpath(path) if File.exist?(path)
|
|
60
|
+
|
|
61
|
+
parent = File.dirname(path)
|
|
62
|
+
parent == path ? path : File.join(canonical(parent), File.basename(path))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def inside?(path, dir)
|
|
66
|
+
canonical(path).start_with?("#{canonical(dir).chomp(File::SEPARATOR)}#{File::SEPARATOR}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
data/lib/rpeft/errors.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
class Error < StandardError; end
|
|
5
|
+
|
|
6
|
+
# Python, PyCall, or a required Python package is missing or unusable.
|
|
7
|
+
class DependencyError < Error; end
|
|
8
|
+
|
|
9
|
+
# Invalid options were passed to the Ruby API.
|
|
10
|
+
class ConfigurationError < Error; end
|
|
11
|
+
|
|
12
|
+
# The dataset file is missing or not in the expected JSONL format.
|
|
13
|
+
class DatasetError < Error; end
|
|
14
|
+
|
|
15
|
+
# The base model or an adapter directory could not be loaded.
|
|
16
|
+
class ModelLoadError < Error; end
|
|
17
|
+
|
|
18
|
+
# LoRA training failed. The original Python error is available via #cause.
|
|
19
|
+
class TrainingError < Error; end
|
|
20
|
+
|
|
21
|
+
# Merging the adapter into the base model failed.
|
|
22
|
+
class MergeError < Error; end
|
|
23
|
+
|
|
24
|
+
# Text generation failed.
|
|
25
|
+
class GenerationError < Error; end
|
|
26
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
# @api private
|
|
5
|
+
#
|
|
6
|
+
# Shared request building for text generation. Model#generate runs
|
|
7
|
+
# a standalone model; Adapter#generate adds adapter_path so the backend
|
|
8
|
+
# applies the adapter on top of the base model without merging.
|
|
9
|
+
#
|
|
10
|
+
# Exactly one of prompt: and messages: is given. With messages: the backend renders
|
|
11
|
+
# the transcript with the tokenizer's chat template before generating.
|
|
12
|
+
module Generation
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def run(model:, prompt: nil, messages: nil, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil, adapter_path: nil)
|
|
16
|
+
# Everything is validated before Device.resolve, which reaches the backend:
|
|
17
|
+
# a bad argument should not cost a Python round trip.
|
|
18
|
+
validate(max_new_tokens, temperature, top_p, seed)
|
|
19
|
+
input = input_for(prompt, messages)
|
|
20
|
+
|
|
21
|
+
request = {
|
|
22
|
+
'model' => model,
|
|
23
|
+
'max_new_tokens' => max_new_tokens,
|
|
24
|
+
'device' => Device.resolve(device).to_s,
|
|
25
|
+
'trust_remote_code' => false
|
|
26
|
+
}.merge(input)
|
|
27
|
+
request['adapter_path'] = adapter_path if adapter_path
|
|
28
|
+
request['temperature'] = temperature if temperature
|
|
29
|
+
request['top_p'] = top_p if top_p
|
|
30
|
+
request['seed'] = seed if seed
|
|
31
|
+
|
|
32
|
+
RPEFT.backend.generate(request)['completion']
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def input_for(prompt, messages)
|
|
36
|
+
unless messages
|
|
37
|
+
return {'prompt' => prompt} if prompt.is_a?(String) && !prompt.empty?
|
|
38
|
+
|
|
39
|
+
raise ConfigurationError, "prompt must be a non-empty String, got #{prompt.inspect}"
|
|
40
|
+
end
|
|
41
|
+
return {'messages' => messages} if Messages.valid?(messages)
|
|
42
|
+
|
|
43
|
+
raise ConfigurationError, "messages must be a non-empty Array of Hashes with a role of system, user, or assistant and String content, got #{messages.inspect}"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def validate(max_new_tokens, temperature, top_p, seed)
|
|
47
|
+
if !max_new_tokens.is_a?(Integer) || !max_new_tokens.positive?
|
|
48
|
+
raise ConfigurationError, "max_new_tokens must be a positive Integer, got #{max_new_tokens.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
if temperature && !(temperature.is_a?(Numeric) && temperature.positive?)
|
|
52
|
+
raise ConfigurationError, "temperature must be a positive number, got #{temperature.inspect}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
if top_p && !(top_p.is_a?(Numeric) && top_p.positive? && top_p <= 1)
|
|
56
|
+
raise ConfigurationError, "top_p must be within (0.0, 1.0], got #{top_p.inspect}"
|
|
57
|
+
end
|
|
58
|
+
return if seed.nil? || seed.is_a?(Integer)
|
|
59
|
+
|
|
60
|
+
raise ConfigurationError, "seed must be an Integer, got #{seed.inspect}"
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module RPEFT
|
|
8
|
+
module LoRA
|
|
9
|
+
# A trained LoRA adapter directory: the learned B A matrices plus the configuration
|
|
10
|
+
# and metadata needed to merge them back into the base model.
|
|
11
|
+
class Adapter
|
|
12
|
+
METADATA_FILE = 'rpeft_metadata.json'
|
|
13
|
+
PEFT_CONFIG_FILE = 'adapter_config.json'
|
|
14
|
+
# Where the backend keeps checkpoints while training; see python/rpeft_backend/_training.py.
|
|
15
|
+
TRAINER_DIR = '.trainer'
|
|
16
|
+
REPLACE_MARKERS = [METADATA_FILE, PEFT_CONFIG_FILE].freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :path, :base_model, :rank, :alpha, :dropout, :target_modules, :metadata
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
def load(path, base_model: nil)
|
|
22
|
+
dir = File.expand_path(Arguments.path(path, 'path'))
|
|
23
|
+
base_model = Arguments.model(base_model, 'base_model') if base_model
|
|
24
|
+
metadata_file = File.join(dir, METADATA_FILE)
|
|
25
|
+
peft_file = File.join(dir, PEFT_CONFIG_FILE)
|
|
26
|
+
|
|
27
|
+
if File.file?(metadata_file)
|
|
28
|
+
from_metadata(dir, parse_json(metadata_file), base_model)
|
|
29
|
+
elsif File.file?(peft_file)
|
|
30
|
+
from_peft_config(dir, parse_json(peft_file), base_model)
|
|
31
|
+
else
|
|
32
|
+
raise ModelLoadError, "#{dir} is not a LoRA adapter directory (no #{METADATA_FILE} or #{PEFT_CONFIG_FILE} found)"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def from_metadata(dir, metadata, base_model)
|
|
39
|
+
lora = metadata['lora'] || {}
|
|
40
|
+
new(
|
|
41
|
+
path: dir,
|
|
42
|
+
base_model: base_model || metadata['base_model'],
|
|
43
|
+
rank: lora['rank'],
|
|
44
|
+
alpha: lora['alpha'],
|
|
45
|
+
dropout: lora['dropout'],
|
|
46
|
+
target_modules: lora['target_modules'],
|
|
47
|
+
metadata: metadata
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Fallback for adapter directories produced by plain PEFT,
|
|
52
|
+
# which have no rpeft_metadata.json.
|
|
53
|
+
def from_peft_config(dir, config, base_model)
|
|
54
|
+
new(
|
|
55
|
+
path: dir,
|
|
56
|
+
base_model: base_model || config['base_model_name_or_path'],
|
|
57
|
+
rank: config['r'],
|
|
58
|
+
alpha: config['lora_alpha'],
|
|
59
|
+
dropout: config['lora_dropout'],
|
|
60
|
+
target_modules: config['target_modules'],
|
|
61
|
+
metadata: {}
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse_json(file)
|
|
66
|
+
JSON.parse(File.read(file))
|
|
67
|
+
rescue JSON::ParserError => e
|
|
68
|
+
raise ModelLoadError, "failed to parse #{file}: #{e.message}"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def initialize(path:, base_model:, rank: nil, alpha: nil, dropout: nil, target_modules: nil, metadata: {})
|
|
73
|
+
@path = File.expand_path(path)
|
|
74
|
+
@base_model = base_model
|
|
75
|
+
@rank = rank
|
|
76
|
+
@alpha = alpha
|
|
77
|
+
@dropout = dropout
|
|
78
|
+
@target_modules = target_modules
|
|
79
|
+
@metadata = metadata.freeze
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def save(dest)
|
|
83
|
+
@path = Directory.move(@path, Arguments.path(dest, 'dest'), replace_markers: REPLACE_MARKERS)
|
|
84
|
+
self
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Merges the learned B A update into the base model weights
|
|
88
|
+
# (W = W0 + B A) and returns the resulting standalone Model.
|
|
89
|
+
def merge(output_dir: nil)
|
|
90
|
+
unless base_model
|
|
91
|
+
raise MergeError, 'base model is unknown; pass base_model: to Adapter.load ' \
|
|
92
|
+
'(or --model to the CLI)'
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
output_dir = Arguments.path(output_dir, 'output_dir') if output_dir
|
|
96
|
+
empty_dir = output_dir && File.directory?(output_dir) && Dir.empty?(output_dir)
|
|
97
|
+
fresh = output_dir.nil? || !File.exist?(output_dir) || empty_dir
|
|
98
|
+
merged_dir = output_dir ? Directory.prepare(output_dir, markers: Model::REPLACE_MARKERS) : Dir.mktmpdir('rpeft-merged-')
|
|
99
|
+
result = RPEFT.backend.merge_lora(
|
|
100
|
+
'adapter_path' => path,
|
|
101
|
+
'output_dir' => merged_dir,
|
|
102
|
+
'base_model' => base_model
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
model_dir = result['model_path'] || merged_dir
|
|
106
|
+
merged_base_model = result['base_model'] || base_model
|
|
107
|
+
FileUtils.mkdir_p(model_dir)
|
|
108
|
+
Model.write_metadata(model_dir, base_model: merged_base_model)
|
|
109
|
+
|
|
110
|
+
Model.new(path: model_dir, base_model: merged_base_model)
|
|
111
|
+
rescue StandardError
|
|
112
|
+
# A partial merge has weights but no marker, which would make Directory.prepare refuse the retry.
|
|
113
|
+
# Only what this merge created is removed; a model merged there before keeps its marker.
|
|
114
|
+
discard_partial_merge(merged_dir, keep_dir: empty_dir) if merged_dir && fresh
|
|
115
|
+
raise
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Generates with the adapter applied on top of the base model, without merging.
|
|
119
|
+
# Useful for verifying an adapter before producing a merged model.
|
|
120
|
+
def generate(prompt, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
|
|
121
|
+
require_base_model
|
|
122
|
+
|
|
123
|
+
Generation.run(
|
|
124
|
+
model: base_model,
|
|
125
|
+
prompt: prompt,
|
|
126
|
+
adapter_path: path,
|
|
127
|
+
max_new_tokens: max_new_tokens,
|
|
128
|
+
device: device,
|
|
129
|
+
temperature: temperature,
|
|
130
|
+
top_p: top_p,
|
|
131
|
+
seed: seed
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Continues a chat transcript with the adapter applied on top of the base model.
|
|
136
|
+
def chat(messages, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
|
|
137
|
+
require_base_model
|
|
138
|
+
|
|
139
|
+
Generation.run(
|
|
140
|
+
model: base_model,
|
|
141
|
+
messages: messages,
|
|
142
|
+
adapter_path: path,
|
|
143
|
+
max_new_tokens: max_new_tokens,
|
|
144
|
+
device: device,
|
|
145
|
+
temperature: temperature,
|
|
146
|
+
top_p: top_p,
|
|
147
|
+
seed: seed
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# An adapter is a delta against specific base weights, so it cannot be applied without knowing which model it belongs to.
|
|
154
|
+
def require_base_model
|
|
155
|
+
return if base_model
|
|
156
|
+
|
|
157
|
+
raise GenerationError, 'base model is unknown; pass base_model: to Adapter.load (or --model to the CLI)'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def discard_partial_merge(dir, keep_dir:)
|
|
161
|
+
if keep_dir
|
|
162
|
+
Dir.children(dir).each do |child|
|
|
163
|
+
FileUtils.rm_rf(File.join(dir, child))
|
|
164
|
+
end
|
|
165
|
+
else
|
|
166
|
+
FileUtils.rm_rf(dir)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
data/lib/rpeft/lora.rb
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'tmpdir'
|
|
7
|
+
|
|
8
|
+
module RPEFT
|
|
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:
|
|
11
|
+
#
|
|
12
|
+
# W = W0 + B A
|
|
13
|
+
#
|
|
14
|
+
# The `rank:` option is exactly that r.
|
|
15
|
+
module LoRA
|
|
16
|
+
POSITIVE_INTEGER_OPTIONS = %i(rank epochs batch_size max_length).freeze
|
|
17
|
+
POSITIVE_NUMBER_OPTIONS = %i(alpha learning_rate).freeze
|
|
18
|
+
|
|
19
|
+
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
|
+
options = {
|
|
22
|
+
rank: rank,
|
|
23
|
+
alpha: alpha,
|
|
24
|
+
dropout: dropout,
|
|
25
|
+
target_modules: target_modules,
|
|
26
|
+
epochs: epochs,
|
|
27
|
+
batch_size: batch_size,
|
|
28
|
+
learning_rate: learning_rate,
|
|
29
|
+
max_length: max_length,
|
|
30
|
+
checkpoints: checkpoints,
|
|
31
|
+
resume: resume
|
|
32
|
+
}
|
|
33
|
+
model = Arguments.model(model, 'model')
|
|
34
|
+
output_dir = Arguments.path(output_dir, 'output_dir') if output_dir
|
|
35
|
+
validate_options(options)
|
|
36
|
+
validate_resume(resume, output_dir)
|
|
37
|
+
training_dataset = Dataset.coerce(dataset)
|
|
38
|
+
evaluation_dataset = coerce_eval_dataset(eval_dataset, training_dataset)
|
|
39
|
+
adapter_dir = prepare_output_dir(output_dir)
|
|
40
|
+
resolved_device = Device.resolve(device)
|
|
41
|
+
|
|
42
|
+
options[:eval_dataset] = evaluation_dataset
|
|
43
|
+
result = RPEFT.backend.train_lora(
|
|
44
|
+
request_payload(model, training_dataset, adapter_dir, resolved_device, options)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
build_adapter(
|
|
48
|
+
adapter_dir: adapter_dir,
|
|
49
|
+
model: model,
|
|
50
|
+
dataset_name: training_dataset.name,
|
|
51
|
+
device: resolved_device,
|
|
52
|
+
options: options,
|
|
53
|
+
result: result
|
|
54
|
+
)
|
|
55
|
+
rescue StandardError
|
|
56
|
+
# A temporary directory is never returned to the caller, so a failed run leaves nothing anyone could
|
|
57
|
+
# resume from. An explicit output_dir is kept for resume: true.
|
|
58
|
+
FileUtils.rm_rf(adapter_dir) if adapter_dir && output_dir.nil?
|
|
59
|
+
raise
|
|
60
|
+
ensure
|
|
61
|
+
# Datasets given as Dataset objects belong to the caller and may be reused for another run.
|
|
62
|
+
training_dataset.discard if training_dataset && !training_dataset.equal?(dataset)
|
|
63
|
+
evaluation_dataset.discard if evaluation_dataset && !evaluation_dataset.equal?(eval_dataset)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validate_options(options)
|
|
69
|
+
POSITIVE_INTEGER_OPTIONS.each do |key|
|
|
70
|
+
value = options[key]
|
|
71
|
+
if !value.is_a?(Integer) || !value.positive?
|
|
72
|
+
raise ConfigurationError, "#{key} must be a positive Integer, got #{value.inspect}"
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
POSITIVE_NUMBER_OPTIONS.each do |key|
|
|
76
|
+
value = options[key]
|
|
77
|
+
if !value.is_a?(Numeric) || !value.positive?
|
|
78
|
+
raise ConfigurationError, "#{key} must be a positive number, got #{value.inspect}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
validate_dropout(options[:dropout])
|
|
82
|
+
validate_target_modules(options[:target_modules])
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def validate_dropout(dropout)
|
|
86
|
+
return if dropout.is_a?(Numeric) && dropout >= 0 && dropout < 1
|
|
87
|
+
|
|
88
|
+
raise ConfigurationError, "dropout must be a number within 0.0...1.0, got #{dropout.inspect}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def validate_target_modules(target_modules)
|
|
92
|
+
return if target_modules.nil?
|
|
93
|
+
return if target_modules.is_a?(Array) && !target_modules.empty? && target_modules.all?(String)
|
|
94
|
+
|
|
95
|
+
raise ConfigurationError, "target_modules must be nil (auto) or a non-empty Array of Strings, got #{target_modules.inspect}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Without an explicit output_dir every run gets a fresh temporary directory, so there would never be a checkpoint of an earlier run to resume from.
|
|
99
|
+
def validate_resume(resume, output_dir)
|
|
100
|
+
return if !resume || !output_dir.nil?
|
|
101
|
+
|
|
102
|
+
raise ConfigurationError, 'resume: true requires an explicit output_dir: to find the previous checkpoints'
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The backend tokenizes both datasets the same way, so mixing formats would silently mis-render the evaluation set.
|
|
106
|
+
def coerce_eval_dataset(eval_dataset, training_dataset)
|
|
107
|
+
return nil unless eval_dataset
|
|
108
|
+
|
|
109
|
+
dataset = Dataset.coerce(eval_dataset)
|
|
110
|
+
return dataset if dataset.backend_format == training_dataset.backend_format
|
|
111
|
+
|
|
112
|
+
# Raised before train assigns the dataset, so its ensure clause cannot discard it.
|
|
113
|
+
dataset.discard unless dataset.equal?(eval_dataset)
|
|
114
|
+
raise ConfigurationError, "eval_dataset format (#{dataset.format}) must match dataset format (#{training_dataset.format})"
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def prepare_output_dir(output_dir)
|
|
118
|
+
return Dir.mktmpdir('rpeft-adapter-') unless output_dir
|
|
119
|
+
|
|
120
|
+
# TRAINER_DIR marks a run that stopped before saving the adapter, which resume: true picks up.
|
|
121
|
+
Directory.prepare(output_dir, markers: [*Adapter::REPLACE_MARKERS, Adapter::TRAINER_DIR])
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def request_payload(model, dataset, adapter_dir, device, options)
|
|
125
|
+
payload = {
|
|
126
|
+
'base_model' => model,
|
|
127
|
+
'dataset_path' => dataset.path,
|
|
128
|
+
'dataset_format' => dataset.backend_format,
|
|
129
|
+
'output_dir' => adapter_dir,
|
|
130
|
+
'text_field' => 'text',
|
|
131
|
+
'device' => device.to_s,
|
|
132
|
+
'max_length' => options[:max_length],
|
|
133
|
+
'epochs' => options[:epochs],
|
|
134
|
+
'batch_size' => options[:batch_size],
|
|
135
|
+
'learning_rate' => options[:learning_rate],
|
|
136
|
+
'logging_steps' => 5,
|
|
137
|
+
'seed' => 42,
|
|
138
|
+
'trust_remote_code' => false,
|
|
139
|
+
'save_strategy' => options[:checkpoints] ? 'epoch' : 'no',
|
|
140
|
+
'resume_from_checkpoint' => options[:resume],
|
|
141
|
+
'lora' => {
|
|
142
|
+
'r' => options[:rank],
|
|
143
|
+
'alpha' => options[:alpha],
|
|
144
|
+
'dropout' => options[:dropout],
|
|
145
|
+
'target_modules' => options[:target_modules]
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
payload['eval_dataset_path'] = options[:eval_dataset].path if options[:eval_dataset]
|
|
149
|
+
|
|
150
|
+
payload
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_adapter(adapter_dir:, model:, dataset_name:, device:, options:, result:)
|
|
154
|
+
target_modules = result.dig('effective_config', 'lora', 'target_modules') || options[:target_modules]
|
|
155
|
+
metadata = build_metadata(
|
|
156
|
+
model: model,
|
|
157
|
+
dataset_name: dataset_name,
|
|
158
|
+
device: device,
|
|
159
|
+
options: options,
|
|
160
|
+
target_modules: target_modules,
|
|
161
|
+
result: result
|
|
162
|
+
)
|
|
163
|
+
File.write(File.join(adapter_dir, Adapter::METADATA_FILE), "#{JSON.pretty_generate(metadata)}\n")
|
|
164
|
+
|
|
165
|
+
Adapter.new(
|
|
166
|
+
path: adapter_dir,
|
|
167
|
+
base_model: model,
|
|
168
|
+
rank: options[:rank],
|
|
169
|
+
alpha: options[:alpha],
|
|
170
|
+
dropout: options[:dropout],
|
|
171
|
+
target_modules: target_modules,
|
|
172
|
+
metadata: metadata
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def build_metadata(model:, dataset_name:, device:, options:, target_modules:, result:)
|
|
177
|
+
{
|
|
178
|
+
'schema_version' => 1,
|
|
179
|
+
'rpeft_version' => VERSION,
|
|
180
|
+
'kind' => 'lora_adapter',
|
|
181
|
+
'base_model' => model,
|
|
182
|
+
'created_at' => Time.now.iso8601,
|
|
183
|
+
'lora' => {
|
|
184
|
+
'rank' => options[:rank],
|
|
185
|
+
'alpha' => options[:alpha],
|
|
186
|
+
'dropout' => options[:dropout],
|
|
187
|
+
'target_modules' => target_modules
|
|
188
|
+
},
|
|
189
|
+
'training' => {
|
|
190
|
+
'dataset' => dataset_name,
|
|
191
|
+
'epochs' => options[:epochs],
|
|
192
|
+
'batch_size' => options[:batch_size],
|
|
193
|
+
'learning_rate' => options[:learning_rate],
|
|
194
|
+
'max_length' => options[:max_length],
|
|
195
|
+
'device' => result['device'] || device.to_s,
|
|
196
|
+
'train_loss' => result['final_loss'],
|
|
197
|
+
'eval_loss' => result['eval_loss'],
|
|
198
|
+
'duration_seconds' => result['train_runtime_seconds']
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
# @api private
|
|
5
|
+
#
|
|
6
|
+
# Shape check for a chat transcript, shared by the dataset reader and the chat API.
|
|
7
|
+
# Only the predicate is shared: each caller raises the error that fits its context.
|
|
8
|
+
# String and Symbol keys are both accepted, since transcripts read from JSONL have
|
|
9
|
+
# String keys while ones written in Ruby naturally have Symbol keys.
|
|
10
|
+
module Messages
|
|
11
|
+
# Chat templates write the role into the prompt as is, and some drop turns whose role they do not know,
|
|
12
|
+
# so only the roles every template understands are accepted.
|
|
13
|
+
ROLES = %w(system user assistant).freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def valid?(messages)
|
|
18
|
+
return false if !messages.is_a?(Array) || messages.empty?
|
|
19
|
+
|
|
20
|
+
messages.all? { |message|
|
|
21
|
+
next false unless message.is_a?(Hash)
|
|
22
|
+
|
|
23
|
+
role = message['role'] || message[:role]
|
|
24
|
+
content = message['content'] || message[:content]
|
|
25
|
+
|
|
26
|
+
ROLES.include?(role) && content.is_a?(String)
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
data/lib/rpeft/model.rb
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'time'
|
|
5
|
+
|
|
6
|
+
module RPEFT
|
|
7
|
+
# A full model: either a local directory (typically the result of merging a LoRA adapter)
|
|
8
|
+
# or a Hugging Face Hub model id.
|
|
9
|
+
class Model
|
|
10
|
+
METADATA_FILE = 'rpeft_metadata.json'
|
|
11
|
+
# Not config.json: it is a common name outside model directories, and a mistyped save destination
|
|
12
|
+
# holding one would otherwise be deleted.
|
|
13
|
+
REPLACE_MARKERS = [METADATA_FILE].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :path, :base_model
|
|
16
|
+
|
|
17
|
+
# Marks dir as a merged model saved by RPEFT, which is what lets a later #save replace it.
|
|
18
|
+
def self.write_metadata(dir, base_model:)
|
|
19
|
+
metadata = {
|
|
20
|
+
'schema_version' => 1,
|
|
21
|
+
'rpeft_version' => VERSION,
|
|
22
|
+
'kind' => 'merged_model',
|
|
23
|
+
'base_model' => base_model,
|
|
24
|
+
'created_at' => Time.now.iso8601
|
|
25
|
+
}
|
|
26
|
+
File.write(File.join(dir, METADATA_FILE), "#{JSON.pretty_generate(metadata)}\n")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(path:, base_model: nil)
|
|
30
|
+
path = Arguments.path(path, 'path')
|
|
31
|
+
# A Hub id such as "Qwen/Qwen2.5-Coder-0.5B" must stay verbatim; only real local directories are expanded.
|
|
32
|
+
@path = File.directory?(path) ? File.expand_path(path) : path
|
|
33
|
+
@base_model = base_model
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def save(dest)
|
|
37
|
+
@path = Directory.move(@path, Arguments.path(dest, 'dest'), replace_markers: REPLACE_MARKERS)
|
|
38
|
+
self
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Generates a completion for the prompt. Decoding is greedy (and thus deterministic for a given model and prompt)
|
|
42
|
+
# unless temperature or top_p turns sampling on.
|
|
43
|
+
def generate(prompt, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
|
|
44
|
+
Generation.run(
|
|
45
|
+
model: path,
|
|
46
|
+
prompt: prompt,
|
|
47
|
+
max_new_tokens: max_new_tokens,
|
|
48
|
+
device: device,
|
|
49
|
+
temperature: temperature,
|
|
50
|
+
top_p: top_p,
|
|
51
|
+
seed: seed
|
|
52
|
+
)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Continues a chat transcript and returns the assistant's reply. The transcript is
|
|
56
|
+
# rendered with the model's own chat template, which is what a model fine-tuned on
|
|
57
|
+
# chat-format data expects to see.
|
|
58
|
+
def chat(messages, max_new_tokens: 64, device: :auto, temperature: nil, top_p: nil, seed: nil)
|
|
59
|
+
Generation.run(
|
|
60
|
+
model: path,
|
|
61
|
+
messages: messages,
|
|
62
|
+
max_new_tokens: max_new_tokens,
|
|
63
|
+
device: device,
|
|
64
|
+
temperature: temperature,
|
|
65
|
+
top_p: top_p,
|
|
66
|
+
seed: seed
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
data/lib/rpeft.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'rpeft/version'
|
|
4
|
+
require_relative 'rpeft/errors'
|
|
5
|
+
require_relative 'rpeft/arguments'
|
|
6
|
+
require_relative 'rpeft/device'
|
|
7
|
+
require_relative 'rpeft/directory'
|
|
8
|
+
require_relative 'rpeft/dataset'
|
|
9
|
+
require_relative 'rpeft/messages'
|
|
10
|
+
require_relative 'rpeft/generation'
|
|
11
|
+
require_relative 'rpeft/model'
|
|
12
|
+
require_relative 'rpeft/lora'
|
|
13
|
+
require_relative 'rpeft/lora/adapter'
|
|
14
|
+
require_relative 'rpeft/backend'
|
|
15
|
+
require_relative 'rpeft/diagnosis'
|
|
16
|
+
|
|
17
|
+
module RPEFT
|
|
18
|
+
DEVICES = %i(cuda mps cpu).freeze
|
|
19
|
+
|
|
20
|
+
class << self
|
|
21
|
+
# The backend seam. Assign a fake in tests; assign nil to restore the default PyCall-based backend.
|
|
22
|
+
attr_writer :backend
|
|
23
|
+
|
|
24
|
+
def backend
|
|
25
|
+
@backend ||= Backend.default
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The compute device that training will use, preferring CUDA over MPS over CPU.
|
|
29
|
+
def device
|
|
30
|
+
backend.detect_device({})['device'].to_sym
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def check
|
|
34
|
+
Diagnosis.new(backend: backend).report
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|