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.
@@ -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
@@ -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