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,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'optparse'
|
|
4
|
+
|
|
5
|
+
module RPEFT
|
|
6
|
+
module CLI
|
|
7
|
+
# @api private
|
|
8
|
+
class Command
|
|
9
|
+
def self.run(argv, out: $stdout, err: $stderr)
|
|
10
|
+
new(out: out, err: err).run(argv)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(out:, err:)
|
|
14
|
+
@out = out
|
|
15
|
+
@err = err
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run(argv)
|
|
19
|
+
options = {}
|
|
20
|
+
parser = option_parser(options)
|
|
21
|
+
help_requested = false
|
|
22
|
+
parser.on_tail('-h', '--help', 'Show this help') do
|
|
23
|
+
help_requested = true
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
begin
|
|
27
|
+
parser.parse(argv)
|
|
28
|
+
rescue OptionParser::ParseError => e
|
|
29
|
+
return usage_error(e.message, parser)
|
|
30
|
+
end
|
|
31
|
+
if help_requested
|
|
32
|
+
@out.puts parser
|
|
33
|
+
return 0
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
missing = required_options.reject { |key| options.key?(key) }
|
|
37
|
+
return usage_error("missing required option(s): #{format_options(missing)}", parser) unless missing.empty?
|
|
38
|
+
|
|
39
|
+
violation = usage_violation(options)
|
|
40
|
+
return usage_error(violation, parser) if violation
|
|
41
|
+
|
|
42
|
+
execute(options) || 0
|
|
43
|
+
rescue Error => e
|
|
44
|
+
@err.puts "rpeft: #{printable(e.message)}"
|
|
45
|
+
1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
# Control characters other than newline and tab, made visible as \xNN. Model output and messages
|
|
51
|
+
# carried over from Python are arbitrary text, and a terminal would act on an escape sequence in them
|
|
52
|
+
# (moving the cursor, rewriting the screen or the clipboard) instead of showing it.
|
|
53
|
+
def printable(text)
|
|
54
|
+
text.to_s.scrub.gsub(/[\x00-\x08\x0B-\x1F\x7F\u0080-\u009F]/) { |char| format('\\x%02X', char.ord) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def required_options
|
|
58
|
+
[]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Commands override this for constraints that a required-option list cannot express;
|
|
62
|
+
# a String return value is a usage error.
|
|
63
|
+
def usage_violation(_options)
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def usage_error(message, parser)
|
|
68
|
+
@err.puts "rpeft: #{printable(message)}"
|
|
69
|
+
@err.puts parser
|
|
70
|
+
2
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def format_options(keys)
|
|
74
|
+
keys.map { |key| "--#{key.to_s.tr('_', '-')}" }.join(', ')
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
module CLI
|
|
5
|
+
# @api private
|
|
6
|
+
class Doctor < Command
|
|
7
|
+
private
|
|
8
|
+
|
|
9
|
+
def option_parser(_options)
|
|
10
|
+
OptionParser.new do |o|
|
|
11
|
+
o.banner = 'Usage: rpeft doctor'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def execute(_options)
|
|
16
|
+
report = RPEFT.check
|
|
17
|
+
render(report)
|
|
18
|
+
report['status'] == 'ok' ? 0 : 1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def render(report)
|
|
22
|
+
line 'Ruby', "#{report.dig('ruby', 'version')} (rpeft #{report.dig('ruby', 'rpeft')})"
|
|
23
|
+
backend = report['backend'] || {}
|
|
24
|
+
if backend['ok']
|
|
25
|
+
render_python(report['python'])
|
|
26
|
+
render_libraries(report['libraries'])
|
|
27
|
+
render_device(report['device'])
|
|
28
|
+
else
|
|
29
|
+
line 'Backend', "NG #{backend['error']}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Uppercase for consistency with the OK/MISSING library markers;
|
|
33
|
+
# the check API itself keeps the machine-readable lowercase value.
|
|
34
|
+
line 'Status', report['status'].to_s.upcase
|
|
35
|
+
render_hint(report)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def line(label, value)
|
|
39
|
+
padded = "#{label}:".ljust(14)
|
|
40
|
+
@out.puts "#{padded}#{value}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def render_python(python)
|
|
44
|
+
python ||= {}
|
|
45
|
+
line 'Python', "#{python['version']} (#{python['executable']})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render_libraries(libraries)
|
|
49
|
+
(libraries || {}).each do |name, info|
|
|
50
|
+
if !info['installed']
|
|
51
|
+
line name, 'MISSING'
|
|
52
|
+
elsif info['outdated']
|
|
53
|
+
line name, "OUTDATED #{info['version']} (needs #{info['minimum']} or later)"
|
|
54
|
+
else
|
|
55
|
+
line name, "OK #{info['version']}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def render_device(device)
|
|
61
|
+
return line('Device', 'unknown (torch is not installed)') unless device
|
|
62
|
+
|
|
63
|
+
cuda = device.dig('cuda', 'available') ? 'yes' : 'no'
|
|
64
|
+
mps = device.dig('mps', 'available') ? 'yes' : 'no'
|
|
65
|
+
line 'Device', "#{device['recommended']} (CUDA: #{cuda}, MPS: #{mps})"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def render_hint(report)
|
|
69
|
+
return unless report['status'] == 'degraded'
|
|
70
|
+
|
|
71
|
+
@out.puts 'Hint: pip install --upgrade torch transformers peft datasets accelerate'
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
module CLI
|
|
5
|
+
# @api private
|
|
6
|
+
class Generate < Command
|
|
7
|
+
REQUIRED_OPTIONS = %i(prompt).freeze
|
|
8
|
+
PASSTHROUGH_OPTIONS = %i(max_new_tokens device temperature top_p seed).freeze
|
|
9
|
+
DEVICES = %w(auto cuda mps cpu).freeze
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
def option_parser(options)
|
|
14
|
+
OptionParser.new do |o|
|
|
15
|
+
o.banner = 'Usage: rpeft generate {--model DIR_OR_ID | --adapter DIR} --prompt TEXT [options]'
|
|
16
|
+
o.on('--model MODEL', 'Model directory or Hugging Face model id (base model override with --adapter)') do |v|
|
|
17
|
+
options[:model] = v
|
|
18
|
+
end
|
|
19
|
+
o.on('--adapter DIR', 'LoRA adapter directory, applied on top of its base model without merging') do |v|
|
|
20
|
+
options[:adapter] = v
|
|
21
|
+
end
|
|
22
|
+
o.on('--prompt TEXT', 'Prompt to complete') do |v|
|
|
23
|
+
options[:prompt] = v
|
|
24
|
+
end
|
|
25
|
+
o.on('--max-new-tokens N', Integer, 'Maximum number of tokens to generate (default: 64)') do |v|
|
|
26
|
+
options[:max_new_tokens] = v
|
|
27
|
+
end
|
|
28
|
+
o.on('--temperature F', Float, 'Sampling temperature (default: greedy decoding)') do |v|
|
|
29
|
+
options[:temperature] = v
|
|
30
|
+
end
|
|
31
|
+
o.on('--top-p F', Float, 'Nucleus sampling threshold') do |v|
|
|
32
|
+
options[:top_p] = v
|
|
33
|
+
end
|
|
34
|
+
o.on('--seed N', Integer, 'Random seed for sampling') do |v|
|
|
35
|
+
options[:seed] = v
|
|
36
|
+
end
|
|
37
|
+
o.on('--device DEVICE', DEVICES, 'auto, cuda, mps, or cpu (default: auto)') { |v| options[:device] = v.to_sym }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def required_options
|
|
42
|
+
REQUIRED_OPTIONS
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def usage_violation(options)
|
|
46
|
+
return nil if options[:model] || options[:adapter]
|
|
47
|
+
|
|
48
|
+
'missing required option(s): --model or --adapter'
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def execute(options)
|
|
52
|
+
generate_options = {}
|
|
53
|
+
PASSTHROUGH_OPTIONS.each do |key|
|
|
54
|
+
generate_options[key] = options[key] if options.key?(key)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
target = if options[:adapter]
|
|
58
|
+
LoRA::Adapter.load(options[:adapter], base_model: options[:model])
|
|
59
|
+
else
|
|
60
|
+
Model.new(path: options[:model])
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@out.puts printable(target.generate(options[:prompt], **generate_options))
|
|
64
|
+
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
module CLI
|
|
5
|
+
# @api private
|
|
6
|
+
class LoRAMerge < Command
|
|
7
|
+
REQUIRED_OPTIONS = %i(adapter output).freeze
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
def option_parser(options)
|
|
12
|
+
OptionParser.new do |o|
|
|
13
|
+
o.banner = 'Usage: rpeft lora merge --adapter DIR --output DIR [options]'
|
|
14
|
+
o.on('--adapter DIR', 'Trained LoRA adapter directory') do |v|
|
|
15
|
+
options[:adapter] = v
|
|
16
|
+
end
|
|
17
|
+
o.on('--output DIR', 'Directory to save the merged model') do |v|
|
|
18
|
+
options[:output] = v
|
|
19
|
+
end
|
|
20
|
+
o.on('--model MODEL',
|
|
21
|
+
'Base model override (defaults to the one recorded in the adapter)') do |v|
|
|
22
|
+
options[:model] = v
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def required_options
|
|
28
|
+
REQUIRED_OPTIONS
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def execute(options)
|
|
32
|
+
adapter = LoRA::Adapter.load(options[:adapter], base_model: options[:model])
|
|
33
|
+
model = adapter.merge(output_dir: options[:output])
|
|
34
|
+
|
|
35
|
+
@out.puts "Merged model saved to #{model.path}"
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
module CLI
|
|
5
|
+
# @api private
|
|
6
|
+
class LoRATrain < Command
|
|
7
|
+
PASSTHROUGH_OPTIONS = %i(rank alpha dropout target_modules epochs batch_size
|
|
8
|
+
learning_rate max_length device).freeze
|
|
9
|
+
REQUIRED_OPTIONS = %i(model dataset output).freeze
|
|
10
|
+
DEVICES = %w(auto cuda mps cpu).freeze
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def option_parser(options)
|
|
15
|
+
OptionParser.new do |o|
|
|
16
|
+
o.banner = 'Usage: rpeft lora train --model MODEL --dataset FILE --output DIR [options]'
|
|
17
|
+
o.on('--model MODEL', 'Base model (Hugging Face id or local path)') do |v|
|
|
18
|
+
options[:model] = v
|
|
19
|
+
end
|
|
20
|
+
o.on('--dataset FILE', %(Training dataset (JSONL with "text", "instruction"/"output", or "messages" lines))) do |v|
|
|
21
|
+
options[:dataset] = v
|
|
22
|
+
end
|
|
23
|
+
o.on('--output DIR', 'Directory to save the trained adapter') do |v|
|
|
24
|
+
options[:output] = v
|
|
25
|
+
end
|
|
26
|
+
o.on('--rank N', Integer, 'LoRA rank r (default: 8)') do |v|
|
|
27
|
+
options[:rank] = v
|
|
28
|
+
end
|
|
29
|
+
o.on('--alpha N', Float, 'LoRA alpha (default: 16)') do |v|
|
|
30
|
+
options[:alpha] = v
|
|
31
|
+
end
|
|
32
|
+
o.on('--dropout F', Float, 'LoRA dropout (default: 0.05)') do |v|
|
|
33
|
+
options[:dropout] = v
|
|
34
|
+
end
|
|
35
|
+
o.on('--target-modules LIST', Array,
|
|
36
|
+
'Comma-separated module names (default: auto-detect)') do |v|
|
|
37
|
+
options[:target_modules] = v
|
|
38
|
+
end
|
|
39
|
+
o.on('--epochs N', Integer, 'Training epochs (default: 3)') do |v|
|
|
40
|
+
options[:epochs] = v
|
|
41
|
+
end
|
|
42
|
+
o.on('--batch-size N', Integer, 'Batch size (default: 4)') do |v|
|
|
43
|
+
options[:batch_size] = v
|
|
44
|
+
end
|
|
45
|
+
o.on('--learning-rate F', Float, 'Learning rate (default: 0.0002)') do |v|
|
|
46
|
+
options[:learning_rate] = v
|
|
47
|
+
end
|
|
48
|
+
o.on('--max-length N', Integer, 'Max token length (default: 512)') do |v|
|
|
49
|
+
options[:max_length] = v
|
|
50
|
+
end
|
|
51
|
+
o.on('--device DEVICE', DEVICES,
|
|
52
|
+
'auto, cuda, mps, or cpu (default: auto)') { |v| options[:device] = v.to_sym }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def required_options
|
|
57
|
+
REQUIRED_OPTIONS
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def execute(options)
|
|
61
|
+
train_options = {model: options[:model], dataset: options[:dataset],
|
|
62
|
+
output_dir: options[:output]}
|
|
63
|
+
PASSTHROUGH_OPTIONS.each do |key|
|
|
64
|
+
train_options[key] = options[key] if options.key?(key)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
adapter = LoRA.train(**train_options)
|
|
68
|
+
|
|
69
|
+
@out.puts "Adapter saved to #{adapter.path}"
|
|
70
|
+
loss = adapter.metadata.dig('training', 'train_loss')
|
|
71
|
+
@out.puts format('Final training loss: %.4f', loss) if loss
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
data/lib/rpeft/cli.rb
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative '../rpeft'
|
|
4
|
+
require_relative 'cli/command'
|
|
5
|
+
require_relative 'cli/lora_train'
|
|
6
|
+
require_relative 'cli/lora_merge'
|
|
7
|
+
require_relative 'cli/generate'
|
|
8
|
+
require_relative 'cli/doctor'
|
|
9
|
+
|
|
10
|
+
module RPEFT
|
|
11
|
+
# Command line interface. Commands go through the public Ruby API only,
|
|
12
|
+
# never straight to the backend.
|
|
13
|
+
module CLI
|
|
14
|
+
USAGE = <<~USAGE
|
|
15
|
+
Usage: rpeft <command> [options]
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
lora train Fine-tune a base model with LoRA
|
|
19
|
+
lora merge Merge a LoRA adapter into its base model
|
|
20
|
+
generate Generate text from a base or fine-tuned model
|
|
21
|
+
doctor Diagnose the Python environment
|
|
22
|
+
version Print the RPEFT version
|
|
23
|
+
help Show this message
|
|
24
|
+
|
|
25
|
+
Run `rpeft <command> --help` for command options.
|
|
26
|
+
USAGE
|
|
27
|
+
|
|
28
|
+
LORA_USAGE = <<~USAGE
|
|
29
|
+
Usage: rpeft lora <subcommand> [options]
|
|
30
|
+
|
|
31
|
+
Subcommands:
|
|
32
|
+
train Fine-tune a base model with LoRA
|
|
33
|
+
merge Merge a LoRA adapter into its base model
|
|
34
|
+
USAGE
|
|
35
|
+
|
|
36
|
+
class << self
|
|
37
|
+
def run(argv, out: $stdout, err: $stderr)
|
|
38
|
+
command, *rest = argv
|
|
39
|
+
case command
|
|
40
|
+
when 'lora' then run_lora(rest, out: out, err: err)
|
|
41
|
+
when 'generate' then Generate.run(rest, out: out, err: err)
|
|
42
|
+
when 'doctor' then Doctor.run(rest, out: out, err: err)
|
|
43
|
+
when 'version', '--version', '-v'
|
|
44
|
+
out.puts VERSION
|
|
45
|
+
0
|
|
46
|
+
when nil, 'help', '--help', '-h'
|
|
47
|
+
out.puts USAGE
|
|
48
|
+
0
|
|
49
|
+
else
|
|
50
|
+
err.puts "rpeft: unknown command #{command.inspect}"
|
|
51
|
+
err.puts USAGE
|
|
52
|
+
2
|
|
53
|
+
end
|
|
54
|
+
rescue Interrupt
|
|
55
|
+
err.puts 'Interrupted'
|
|
56
|
+
130
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def run_lora(argv, out:, err:)
|
|
62
|
+
subcommand, *rest = argv
|
|
63
|
+
case subcommand
|
|
64
|
+
when 'train' then LoRATrain.run(rest, out: out, err: err)
|
|
65
|
+
when 'merge' then LoRAMerge.run(rest, out: out, err: err)
|
|
66
|
+
when nil, 'help', '--help', '-h'
|
|
67
|
+
out.puts LORA_USAGE
|
|
68
|
+
0
|
|
69
|
+
else
|
|
70
|
+
err.puts "rpeft: unknown lora subcommand #{subcommand.inspect}"
|
|
71
|
+
err.puts LORA_USAGE
|
|
72
|
+
2
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'fileutils'
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
module RPEFT
|
|
8
|
+
# A training dataset. `Dataset.coerce` turns any value accepted by the `dataset:` keyword
|
|
9
|
+
# (a path String, a Pathname, an Enumerable of record Hashes, or a Dataset) into a validated Dataset.
|
|
10
|
+
#
|
|
11
|
+
# Three JSONL line formats are accepted and detected from the first line: `{"text": ...}` is
|
|
12
|
+
# passed through as is, `{"instruction": ..., "input": ..., "output": ...}` is rendered into
|
|
13
|
+
# a text prompt, and `{"messages": [{"role": ..., "content": ...}, ...]}` is passed through
|
|
14
|
+
# for the backend to render with the tokenizer's chat template.
|
|
15
|
+
class Dataset
|
|
16
|
+
def self.coerce(value)
|
|
17
|
+
return value if value.is_a?(Dataset)
|
|
18
|
+
return from_records(value) if value.is_a?(Enumerable)
|
|
19
|
+
|
|
20
|
+
new(Arguments.path(value, 'dataset'))
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Builds a dataset from Ruby records (Hashes in any of the three formats, Symbol keys included)
|
|
24
|
+
# by writing them to a temporary JSONL file, so in-memory data needs no intermediate file of its own.
|
|
25
|
+
def self.from_records(records)
|
|
26
|
+
dir = Dir.mktmpdir('rpeft-dataset-')
|
|
27
|
+
path = File.join(dir, 'records.jsonl')
|
|
28
|
+
written = 0
|
|
29
|
+
|
|
30
|
+
File.open(path, 'w') do |out|
|
|
31
|
+
records.each do |record|
|
|
32
|
+
raise DatasetError, "each dataset record must be a Hash, got #{record.class}" unless record.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
out.puts JSON.generate(record)
|
|
35
|
+
written += 1
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
raise DatasetError, 'dataset is empty: no records were given' if written.zero?
|
|
39
|
+
|
|
40
|
+
new(path, name: nil, temporary_dir: dir)
|
|
41
|
+
rescue StandardError
|
|
42
|
+
FileUtils.rm_rf(dir)
|
|
43
|
+
raise
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# name is the file name the dataset came from, recorded in the adapter metadata;
|
|
47
|
+
# nil for in-memory records, which have no file of their own.
|
|
48
|
+
attr_reader :path, :format, :name
|
|
49
|
+
|
|
50
|
+
def initialize(source, name: File.basename(source.to_s), temporary_dir: nil)
|
|
51
|
+
@name = name
|
|
52
|
+
@temporary_dirs = [temporary_dir].compact
|
|
53
|
+
source_path = existing_file(source)
|
|
54
|
+
@format = detect_format(source_path)
|
|
55
|
+
@path = @format == :instruction ? normalize(source_path) : source_path
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Removes the temporary copies this dataset wrote for the backend. The source file is left alone.
|
|
59
|
+
def discard
|
|
60
|
+
@temporary_dirs.each do |dir|
|
|
61
|
+
FileUtils.rm_rf(dir)
|
|
62
|
+
end
|
|
63
|
+
@temporary_dirs.clear
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The format the backend sees. Instruction datasets are normalized to text here,
|
|
67
|
+
# so only text and messages ever cross the boundary.
|
|
68
|
+
def backend_format
|
|
69
|
+
format == :messages ? 'messages' : 'text'
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def existing_file(source)
|
|
75
|
+
path = File.expand_path(source)
|
|
76
|
+
raise DatasetError, "dataset file not found: #{path}" unless File.file?(path)
|
|
77
|
+
|
|
78
|
+
path
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def detect_format(path)
|
|
82
|
+
record = first_record(path)
|
|
83
|
+
if record.is_a?(Hash)
|
|
84
|
+
return :text if record.key?('text')
|
|
85
|
+
return :instruction if record.key?('instruction') && record.key?('output')
|
|
86
|
+
|
|
87
|
+
if record.key?('messages')
|
|
88
|
+
validate_messages(record['messages'], path)
|
|
89
|
+
return :messages
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
raise DatasetError, "each dataset line must be a JSON object with a \"text\" key, \"instruction\"/\"output\" keys, or a \"messages\" key: #{path}"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_messages(messages, path)
|
|
97
|
+
return if Messages.valid?(messages)
|
|
98
|
+
|
|
99
|
+
raise DatasetError, "\"messages\" must be a non-empty array of objects with a \"role\" of system, user, or assistant and a \"content\" string: #{path}"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def first_record(path)
|
|
103
|
+
line = File.foreach(path).find { |l| !l.strip.empty? }
|
|
104
|
+
raise DatasetError, "dataset is empty: #{path}" unless line
|
|
105
|
+
|
|
106
|
+
parse_line(line, path)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def parse_line(line, path)
|
|
110
|
+
JSON.parse(line)
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
raise DatasetError, "dataset is not JSONL (#{e.message}): #{path}"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Rewrites a non-text dataset into text-format JSONL in
|
|
116
|
+
# a temporary directory, so the backend only ever sees one format.
|
|
117
|
+
def normalize(source_path)
|
|
118
|
+
dir = Dir.mktmpdir('rpeft-dataset-')
|
|
119
|
+
@temporary_dirs << dir
|
|
120
|
+
normalized = File.join(dir, File.basename(source_path))
|
|
121
|
+
|
|
122
|
+
File.open(normalized, 'w') do |out|
|
|
123
|
+
File.foreach(source_path).with_index(1) do |line, lineno|
|
|
124
|
+
next if line.strip.empty?
|
|
125
|
+
|
|
126
|
+
record = parse_line(line, source_path)
|
|
127
|
+
out.puts JSON.generate('text' => render_instruction(record, lineno, source_path))
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
normalized
|
|
132
|
+
rescue StandardError
|
|
133
|
+
discard
|
|
134
|
+
raise
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def render_instruction(record, lineno, source_path)
|
|
138
|
+
instruction = record['instruction']
|
|
139
|
+
output = record['output']
|
|
140
|
+
if !instruction.is_a?(String) || instruction.empty? || !output.is_a?(String)
|
|
141
|
+
raise DatasetError, "line #{lineno}: instruction format requires \"instruction\" and \"output\" strings: #{source_path}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
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
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
data/lib/rpeft/device.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
# @api private
|
|
5
|
+
module Device
|
|
6
|
+
def self.resolve(device)
|
|
7
|
+
device = device.to_sym if device.is_a?(String)
|
|
8
|
+
return RPEFT.device if device == :auto
|
|
9
|
+
|
|
10
|
+
unless device.is_a?(Symbol) && DEVICES.include?(device)
|
|
11
|
+
raise ConfigurationError, "unknown device: #{device.inspect} (expected :auto, :cuda, :mps, or :cpu)"
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
device
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RPEFT
|
|
4
|
+
# Builds the layered environment report behind RPEFT.check and `rpeft doctor`.
|
|
5
|
+
# Never raises: backend failures are folded into the report so the doctor can explain
|
|
6
|
+
# a broken environment.
|
|
7
|
+
class Diagnosis
|
|
8
|
+
REQUIRED_LIBRARIES = %w(torch transformers peft datasets accelerate).freeze
|
|
9
|
+
|
|
10
|
+
def initialize(backend: RPEFT.backend)
|
|
11
|
+
@backend = backend
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def report
|
|
15
|
+
base = {'ruby' => {'version' => RUBY_VERSION, 'rpeft' => VERSION}}
|
|
16
|
+
begin
|
|
17
|
+
diagnostics = @backend.diagnostics
|
|
18
|
+
rescue Error => e
|
|
19
|
+
return base.merge(
|
|
20
|
+
'backend' => {'ok' => false, 'error' => e.message},
|
|
21
|
+
'status' => 'unusable'
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
base.merge(
|
|
26
|
+
'backend' => {'ok' => true, 'version' => diagnostics['backend_version']},
|
|
27
|
+
'python' => diagnostics['python'],
|
|
28
|
+
'libraries' => diagnostics['libraries'],
|
|
29
|
+
'device' => diagnostics['device'],
|
|
30
|
+
'status' => status_of(diagnostics)
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def status_of(diagnostics)
|
|
37
|
+
libraries = diagnostics['libraries'] || {}
|
|
38
|
+
unusable = REQUIRED_LIBRARIES.reject { |name|
|
|
39
|
+
libraries.dig(name, 'installed') && !libraries.dig(name, 'outdated')
|
|
40
|
+
}
|
|
41
|
+
unusable.empty? ? 'ok' : 'degraded'
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|