codefinetuner 0.1.0__py3-none-any.whl

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,145 @@
1
+ import gc
2
+ import json
3
+ import logging
4
+ import math
5
+ from pathlib import Path
6
+
7
+ import torch
8
+ from peft import PeftModel
9
+ from transformers import AutoModelForCausalLM, AutoTokenizer
10
+
11
+ from .config import Config
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _load_lora_model(config: Config, checkpoint_path: Path) ->AutoModelForCausalLM:
18
+ # load base model to CPU first to prevent VRAM fragmentation/OOM
19
+ base_model = AutoModelForCausalLM.from_pretrained(
20
+ pretrained_model_name_or_path=config.model_name,
21
+ dtype=config.model_dtype,
22
+ low_cpu_mem_usage=True
23
+ )
24
+
25
+ # load and attach LoRA adapter and move to device (CUDA, MPS or remains on CPU)
26
+ lora_model = PeftModel.from_pretrained(
27
+ model=base_model,
28
+ model_id=checkpoint_path
29
+ ).to(config.device)
30
+
31
+ lora_model.eval()
32
+
33
+ logger.info(f"Loaded LoRA model from checkpoint {checkpoint_path} to device {config.device}")
34
+ return lora_model
35
+
36
+
37
+ def _load_tokenizer(config: Config) -> AutoTokenizer:
38
+ tokenizer = AutoTokenizer.from_pretrained(config.model_name)
39
+ tokenizer.pad_token = config.fim_pad_token
40
+ tokenizer.padding_side = "right"
41
+ return tokenizer
42
+
43
+
44
+ def _get_fim_perplexity(config: Config, model: AutoModelForCausalLM,
45
+ perplexity_input_token_ids: list[int], perplexity_label_token_ids: list[int]) -> float:
46
+ """
47
+ FIM perplexity: Measures model confidence in the ground truth reference middle code.
48
+ (How surprised is the model by the ground truth reference middle code).
49
+ Lower perplexity indicates higher confidence. perplexity = exp(loss).
50
+ """
51
+ try:
52
+ input_tensor = torch.tensor([perplexity_input_token_ids], device=config.device)
53
+ label_tensor = torch.tensor([perplexity_label_token_ids], device=config.device)
54
+ with torch.inference_mode():
55
+ outputs = model(input_ids=input_tensor,
56
+ labels=label_tensor)
57
+ loss = outputs.loss
58
+
59
+ return math.exp(loss.item())
60
+
61
+ except Exception as e:
62
+ logger.warning(f"Perplexity calcualtin failed, returning inf: {e}")
63
+ return float('inf')
64
+
65
+
66
+ def _generate(config: Config, model: AutoModelForCausalLM, tokenizer: AutoTokenizer, prompt_token_ids: list[int]) -> list:
67
+ model.eval()
68
+ prompt_token_ids_tensor = torch.tensor([prompt_token_ids], device=config.device)
69
+ with torch.inference_mode():
70
+ generated_token_ids_tensor = model.generate(
71
+ input_ids=prompt_token_ids_tensor,
72
+ max_new_tokens=config.generation_max_new_tokens,
73
+ do_sample=config.generation_do_sample,
74
+ temperature=config.generation_temperature,
75
+ top_p=config.generation_top_p,
76
+ pad_token_id=tokenizer.pad_token_id
77
+ )
78
+
79
+ # slice the output, take everything after the input_length, model.generate() functin returns the whole example, not only the generated text
80
+ generated_middle_token_ids_tensor = generated_token_ids_tensor[0][prompt_token_ids_tensor.shape[1] :]
81
+ generated_middle_token_ids = generated_middle_token_ids_tensor.tolist()
82
+
83
+ return generated_middle_token_ids
84
+
85
+
86
+ def _clear_hardware_cache(config: Config) -> None:
87
+ gc.collect()
88
+ if config.device == "cuda":
89
+ torch.cuda.synchronize()
90
+ torch.cuda.empty_cache()
91
+ elif config.device == "mps":
92
+ torch.mps.empty_cache()
93
+
94
+
95
+ def generate_and_save(config: Config, checkpoint_path: Path):
96
+ lora_model = _load_lora_model(config, checkpoint_path)
97
+ tokenizer = _load_tokenizer(config)
98
+ fim_prefix_token_id = tokenizer.convert_tokens_to_ids(config.fim_prefix_token)
99
+ fim_suffix_token_id = tokenizer.convert_tokens_to_ids(config.fim_suffix_token)
100
+ fim_middle_token_id = tokenizer.convert_tokens_to_ids(config.fim_middle_token)
101
+
102
+ line_counter = 0
103
+ try:
104
+ with config.benchmark_dataset_path.open("r") as benchmark_dataset_file, \
105
+ config.benchmark_evaluation_results_path.open("w") as evaluation_results_file:
106
+
107
+ for line in benchmark_dataset_file:
108
+ benchmark_example = json.loads(line)
109
+ prompt_token_ids = ([fim_prefix_token_id] + benchmark_example["prefix_token_ids"] +
110
+ [fim_suffix_token_id] + benchmark_example["suffix_token_ids"] +
111
+ [fim_middle_token_id])
112
+
113
+ perplexity_input_token_ids = benchmark_example["example_token_ids"]
114
+ perplexity_label_token_ids = perplexity_input_token_ids.copy()
115
+ perplexity_label_token_ids[:len(prompt_token_ids)] = [config.label_pad_token_id] * len(prompt_token_ids) # mask labels all except ground truth reference middle tokens
116
+
117
+ lora_generated_middle_token_ids = _generate(config, lora_model, tokenizer, prompt_token_ids)
118
+ lora_perplexity = _get_fim_perplexity(config, lora_model, perplexity_input_token_ids, perplexity_label_token_ids)
119
+
120
+ with lora_model.disable_adapter():
121
+ base_generated_middle_token_ids = _generate(config, lora_model, tokenizer, prompt_token_ids)
122
+ base_perplexity = _get_fim_perplexity(config, lora_model, perplexity_input_token_ids, perplexity_label_token_ids)
123
+
124
+ reference_middle = benchmark_example["middle"]
125
+ lora_generated_middle = tokenizer.decode(lora_generated_middle_token_ids, skip_special_tokens=True)
126
+ base_generated_middle = tokenizer.decode(base_generated_middle_token_ids, skip_special_tokens=True)
127
+ result = {
128
+ "example_id": line_counter,
129
+ "reference_middle": reference_middle,
130
+ "base_generated_middle": base_generated_middle,
131
+ "lora_generated_middle": lora_generated_middle,
132
+ "base_perplexity": base_perplexity,
133
+ "lora_perplexity": lora_perplexity
134
+ }
135
+ evaluation_results_file.write(json.dumps(result) + "\n")
136
+
137
+ line_counter += 1
138
+ if line_counter % 10 == 0:
139
+ _clear_hardware_cache(config)
140
+ logger.info(f"Processed {line_counter} benchmark examples")
141
+
142
+ except Exception as e:
143
+ raise RuntimeError(f"Generation failed at example {line_counter}: {e}") from e
144
+
145
+ logger.info(f"Successfully generated and saved {line_counter} number of examples to {config.benchmark_evaluation_results_path}.")
@@ -0,0 +1,160 @@
1
+ import logging
2
+ import re
3
+ from nltk.tokenize import word_tokenize
4
+ from nltk.translate.bleu_score import SmoothingFunction, sentence_bleu
5
+
6
+ from .codebleu_shim import codebleu_score
7
+ from .config import Config
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def _codebleu_structure_valid(config: Config, reference: str) -> bool:
14
+ """
15
+ Checks if the reference code is structurally complex enough for CodeBLEU.
16
+ Performs a self-match test focusing only on Syntax (AST) and Data-flow.
17
+ If the reference is too simple (e.g., import blocks, comment)
18
+ to build these structures, the example is skipped.
19
+ """
20
+ # suppress root logger warnings that are raised if it is not possible to calculate all 4 metrics
21
+ root_logger = logging.getLogger()
22
+ original_level = root_logger.getEffectiveLevel()
23
+ root_logger.setLevel(logging.ERROR)
24
+
25
+ try:
26
+ test_weights = (0.0, 0.0, 0.5, 0.5)
27
+ result = codebleu_score([reference], [reference],
28
+ lang=config.codebleu_language,
29
+ weights=test_weights)
30
+
31
+ syntax_valid = result.get('syntax_match_score', 0) > 0
32
+ dataflow_valid = result.get('dataflow_match_score', 0) > 0
33
+ return syntax_valid and dataflow_valid
34
+ except Exception:
35
+ return False
36
+ finally:
37
+ # restore logger
38
+ root_logger.setLevel(original_level)
39
+
40
+
41
+ def get_codebleu(config: Config, reference: str, prediction: str) -> tuple[float, bool]:
42
+ """
43
+ CodeBLEU: Computes weighted combination of four different similarity metrics:
44
+ 1. N-gram Match: Standard surface-level text overlap.
45
+ 2. Weighted N-gram: Text overlap with priority on keywords (if, else, etc.).
46
+ 3. Syntax (AST): Structural similarity between Abstract Syntax Trees.
47
+ 4. Data-flow: Logical similarity based on variable dependencies and usage.
48
+
49
+ Examples are skipped if structural components (AST/Data-flow) cannot be
50
+ extracted from the reference. This prevents the final average score from
51
+ being unfairly lowered by snippets that cannot be properly parsed,
52
+ ensuring a more accurate representation of model quality.
53
+
54
+ Standard weights are: [0.25, 0.25, 0.25, 0.25].
55
+ CodeBLEU = (codebleu_ngram_weight * ngram_score) +
56
+ (codebleu_weighted_ngram_weight * weighted_ngram_score) +
57
+ (codebleu_syntax_ast_weight * syntax_ast_score) +
58
+ (codebleu_dataflow_weight * dataflow_score)
59
+ """
60
+ if not _codebleu_structure_valid(config, reference):
61
+ return (0.0, False)
62
+
63
+ try:
64
+ codebleu_algorithm_weights = (
65
+ config.codebleu_ngram_weight,
66
+ config.codebleu_weighted_ngram_weight,
67
+ config.codebleu_syntax_ast_weight,
68
+ config.codebleu_dataflow_weight
69
+ )
70
+
71
+ result = codebleu_score(
72
+ [reference], [prediction],
73
+ lang=config.codebleu_language,
74
+ weights=codebleu_algorithm_weights
75
+ )
76
+
77
+ return (float(result['codebleu']), True)
78
+
79
+ except Exception as e:
80
+ logger.warning(f"CodeBLEU calculation failed, returning (0.0, False): {e}")
81
+ return (0.0, False)
82
+
83
+
84
+ def get_sentencebleu(config: Config, reference: str, prediction: str) -> float:
85
+ """
86
+ SentenceBLEU: Measures n-gram overlap between reference and prediction.
87
+ It rewards matching sequences of words (1-4) and uses smoothing
88
+ (Method1: Adds a tiny epsilon to all n-gram counts) to prevent a total
89
+ 0.0 score when long sequences (e.g. 4-grams) don't match exactly.
90
+ """
91
+ config.ensure_nltk_initialized() # make sure that required nltk downloads are done
92
+
93
+ try:
94
+ reference_tokens = word_tokenize(reference)
95
+ prediction_tokens = word_tokenize(prediction)
96
+
97
+ weights = (
98
+ config.sentencebleu_ngram_weight_1,
99
+ config.sentencebleu_ngram_weight_2,
100
+ config.sentencebleu_ngram_weight_3,
101
+ config.sentencebleu_ngram_weight_4
102
+ )
103
+
104
+ smoothing = SmoothingFunction().method1
105
+
106
+ score = sentence_bleu(
107
+ [reference_tokens],
108
+ prediction_tokens,
109
+ weights=weights,
110
+ smoothing_function=smoothing
111
+ )
112
+
113
+ return float(score)
114
+
115
+ except Exception as e:
116
+ logger.warning(f"SentenceBLEU calculation failed, returning 0.0: {e}")
117
+ return 0.0
118
+
119
+
120
+ def get_exact_match(reference: str, prediction: str) -> float:
121
+ """Exact match is 1.0 if identical, 0.0 otherwise. Collapese all whitespaces."""
122
+ try:
123
+ # re.sub(r'\s+', ' ', text.strip()): Collapses whitespace to compare logic regardless of formatting.
124
+ ref_norm = re.sub(r'\s+', ' ', reference.strip())
125
+ pred_norm = re.sub(r'\s+', ' ', prediction.strip())
126
+
127
+ if ref_norm == pred_norm:
128
+ return 1.0
129
+ else:
130
+ return 0.0
131
+ except Exception as e:
132
+ logger.warning(f"Exact match calculation failed, returning 0.0: {e}")
133
+ return 0.0
134
+
135
+
136
+ def get_line_match(config: Config, reference: str, prediction: str) -> float:
137
+ """Check if the first n lines match, ignoring trailing whitespace."""
138
+ try:
139
+ n = config.line_match_number_of_lines
140
+
141
+ # line.rstrip(): Removes trailing whitespace while preserving leading indentation.
142
+ ref_lines_stripped = []
143
+ for line in reference.splitlines()[:n]:
144
+ ref_lines_stripped.append(line.rstrip())
145
+
146
+ pred_lines_stripped = []
147
+ for line in prediction.splitlines()[:n]:
148
+ pred_lines_stripped.append(line.rstrip())
149
+
150
+ # Ensure both lists have the required number of lines
151
+ if len(pred_lines_stripped) < n or len(ref_lines_stripped) < n:
152
+ return 0.0
153
+
154
+ if pred_lines_stripped == ref_lines_stripped:
155
+ return 1.0
156
+ else:
157
+ return 0.0
158
+ except Exception as e:
159
+ logger.warning(f"Line match calculation failed, returning 0.0: {e}")
160
+ return 0.0
@@ -0,0 +1,125 @@
1
+ import argparse
2
+ import logging
3
+ import sys
4
+ import logging.config
5
+ from pathlib import Path
6
+
7
+ from transformers.trainer_utils import get_last_checkpoint
8
+
9
+ from .config import Config
10
+ from .benchmark import create_benchmark_dataset
11
+ from .generate import generate_and_save
12
+ from .evaluate import evaluate_and_save
13
+ from .analyze import analyze_metric, save_all_metric_stats, get_plot_path, plot_metric_and_save, plot_all_metric_averages_and_save
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def _parse_args() -> argparse.Namespace:
20
+ parser = argparse.ArgumentParser(description="Run evaluation")
21
+ parser.add_argument(
22
+ "--config",
23
+ type=Path,
24
+ required=True,
25
+ help="Path to the pipeline YAML configuration file.",
26
+ )
27
+ parser.add_argument(
28
+ "--log-level",
29
+ type=str,
30
+ default="INFO",
31
+ choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
32
+ help="Logging level.",
33
+ )
34
+ return parser.parse_args()
35
+
36
+
37
+ def _setup_logger(log_level: str) -> None:
38
+ """
39
+ Configure the root logger ("") to capture logs from the entry point (__main__),
40
+ all internal sub-modules, and third-party libraries via a single handler.
41
+ """
42
+ logger_config = {
43
+ "version": 1,
44
+ "disable_existing_loggers": False,
45
+ "formatters": {
46
+ "standard": {
47
+ "format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
48
+ }
49
+ },
50
+ "handlers": {
51
+ "stderr_handler": {
52
+ "level": log_level,
53
+ "class": "logging.StreamHandler",
54
+ "formatter": "standard",
55
+ }
56
+ },
57
+ "loggers": {
58
+ "": { # "" corresponds to root logger
59
+ "handlers": ["stderr_handler"],
60
+ "level": log_level,
61
+ "propagate": False,
62
+ }
63
+ }
64
+ }
65
+ logging.config.dictConfig(logger_config)
66
+
67
+
68
+ def _silence_noisy_third_party_loggers() -> None:
69
+ """Reduce log level of known noisy third‑party loggers."""
70
+ logging.getLogger("matplotlib").setLevel(logging.WARNING)
71
+ logging.getLogger("matplotlib.font_manager").setLevel(logging.WARNING)
72
+ logging.getLogger("nltk").setLevel(logging.WARNING)
73
+ # add more as needed
74
+
75
+
76
+ def _ensure_checkpoints(config: Config) -> None:
77
+ checkpoints_dir = config.trainer_checkpoints_dir_path
78
+ if not checkpoints_dir.exists():
79
+ raise RuntimeError("Checkpoint directory not found.")
80
+ if not list(checkpoints_dir.iterdir()):
81
+ raise RuntimeError("No checkpoints in directory.")
82
+
83
+
84
+ def run(config: Config) -> None:
85
+ if not config.benchmark_use_existing_dataset or not config.benchmark_dataset_path.exists():
86
+ dataset_len = create_benchmark_dataset(config)
87
+ logger.info(f"Created new benchmark dataset '{config.benchmark_dataset_path}' with '{dataset_len}' examples")
88
+ else:
89
+ logger.info(f"Proceeding with existing file '{config.benchmark_dataset_path}'...")
90
+
91
+ if not config.plot_only:
92
+ _ensure_checkpoints(config)
93
+ if config.trainer_checkpoint == "last":
94
+ checkpoint_path = get_last_checkpoint(config.trainer_checkpoints_dir_path)
95
+ else:
96
+ checkpoint_path = config.trainer_checkpoints_dir_path / config.trainer_checkpoint
97
+ generate_and_save(config, checkpoint_path)
98
+ evaluate_and_save(config)
99
+
100
+ all_metric_stats_np = []
101
+ for metric_name, higher_is_better in config.metric_configs:
102
+ metric_stats_np = analyze_metric(config, metric_name, higher_is_better)
103
+ plot_path = get_plot_path(config.benchmark_evaluation_results_dir, metric_name)
104
+ plot_metric_and_save(metric_stats_np, metric_name, plot_path)
105
+ all_metric_stats_np.append(metric_stats_np)
106
+
107
+ all_metric_averages_plot_path = get_plot_path(config.benchmark_evaluation_results_dir, "all_metric_averages")
108
+ plot_all_metric_averages_and_save(all_metric_stats_np, all_metric_averages_plot_path)
109
+ save_all_metric_stats(config, all_metric_stats_np)
110
+
111
+
112
+ def main():
113
+ user_args = _parse_args()
114
+ _setup_logger(user_args.log_level)
115
+ _silence_noisy_third_party_loggers()
116
+ try:
117
+ evaluate_config = Config.load_from_yaml(user_args.config)
118
+ run(evaluate_config)
119
+ except Exception:
120
+ logger.exception("Evaluation failed")
121
+ sys.exit(1)
122
+
123
+
124
+ if __name__ == "__main__":
125
+ main()
File without changes
@@ -0,0 +1,186 @@
1
+ import logging
2
+ import math
3
+ from dataclasses import dataclass, field, fields
4
+ from pathlib import Path
5
+ from typing import List, Any
6
+
7
+ import torch
8
+ from omegaconf import OmegaConf, MISSING
9
+
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ @dataclass
15
+ class Config:
16
+ # --- Mandatory Parameters ---
17
+ model_name: str = MISSING
18
+ fim_pad_token: str = MISSING
19
+ label_pad_token_id: int = MISSING
20
+
21
+ # --- Model Settings ---
22
+ model_attn_implementation: str = "sdpa" # sdpa = built-in PyTorch implementation of scaled dot product attention, imporves performance and memory efficiency
23
+ model_dtype: Any = field(init=False) # changed to Any to bypass OmegaConf validation
24
+
25
+ # --- LoRA Settings ---
26
+ lora_r: int = 32
27
+ lora_alpha: int = 64
28
+ lora_dropout: float = 0.1
29
+ lora_bias: str = "none"
30
+ lora_target_modules: List[str] = field(default_factory=lambda: [
31
+ "q_proj", "v_proj", "k_proj", "o_proj",
32
+ "gate_proj", "down_proj", "up_proj"
33
+ ])
34
+
35
+ # --- Trainer Hyperparameters ---
36
+ trainer_resume_from_checkpoint: str | None = "last"
37
+ trainer_clear_checkpoint_dir: bool = False
38
+ trainer_num_train_epochs: int = 1
39
+ trainer_per_device_train_batch_size: int = 2
40
+ trainer_per_device_eval_batch_size: int = 2
41
+ trainer_gradient_accumulation_steps: int = 32 # simulates a larger effective batch size, number of forward/backward passes to accumulate before performing one optimizer step
42
+ trainer_learning_rate: float = 2e-5
43
+ trainer_weight_decay: float = 0.1
44
+ trainer_max_grad_norm: float = 1.0
45
+ trainer_lr_scheduler_type: str = "cosine"
46
+ trainer_warmup_steps: int = 50
47
+ trainer_gradient_checkpointing: bool = True # saves memory by storing only key "checkpoint" activations and re-calculating intermediate ones during the backward pass
48
+ trainer_max_steps: int = field(init=False)
49
+
50
+ # --- Logging and Evaluation Strategy ---
51
+ trainer_logging_steps: int = 10 # average training loss over trainer_logging_steps period is calculated and logged
52
+ trainer_eval_strategy: str = "steps"
53
+ trainer_eval_steps: int = 100
54
+ trainer_save_strategy: str = "steps"
55
+ trainer_save_steps: int = 100
56
+ trainer_logging_strategy: str = "steps"
57
+
58
+ # --- Dataset ---
59
+ dataset_shuffle_buffer_size: int = 50000
60
+ dataset_shuffle_seed: int = 0
61
+ dataset_train_dataset_length: int = field(init=False)
62
+
63
+ # --- Hardware Configuration ---
64
+ device: str = field(init=False)
65
+
66
+ # --- Path Management ---
67
+ workspace_path: Path | None = None
68
+ train_dataset_path: Path = field(init=False)
69
+ eval_dataset_path: Path = field(init=False)
70
+ finetune_outputs_dir_path: Path = field(init=False)
71
+ trainer_checkpoints_dir_path: Path = field(init=False)
72
+ trainer_log_path: Path = field(init=False)
73
+ trainer_plot_path: Path = field(init=False)
74
+ lora_adapter_path: Path = field(init=False)
75
+ lora_model_path: Path = field(init=False)
76
+ trainer_model_merge_offload_folder_path: Path = field(init=False)
77
+
78
+ @classmethod
79
+ def load_from_yaml(cls, yaml_path: Path) -> "Config":
80
+ if not yaml_path.exists():
81
+ raise FileNotFoundError(f"Config file not found: {yaml_path}")
82
+
83
+ logger.info(f"Loading configuration from {yaml_path}")
84
+ config_dict = OmegaConf.structured(cls)
85
+ try:
86
+ yaml_file_node = OmegaConf.load(yaml_path)
87
+ except Exception as e:
88
+ raise ValueError(f"Failed to load YAML config {yaml_path}") from e
89
+
90
+ yaml_file_dict = OmegaConf.to_container(yaml_file_node, resolve=True)
91
+ yaml_finetune_dict = yaml_file_dict.get("finetune", {})
92
+
93
+ yaml_finetune_valid_dict = {}
94
+ # Filter YAML fields to include only those defined in the Config dataclass.
95
+ # This prevents OmegaConf from raising an AttributeError when encountering
96
+ # global YAML anchors or keys not present in the current Config dataclass.
97
+ for field in fields(cls):
98
+ if field.name in yaml_finetune_dict:
99
+ yaml_finetune_valid_dict[field.name] = yaml_finetune_dict[field.name]
100
+ logger.debug(f"Filtered YAML configuration: {yaml_finetune_valid_dict}")
101
+
102
+ merged_config_dict = OmegaConf.merge(config_dict, yaml_finetune_valid_dict)
103
+ return OmegaConf.to_object(merged_config_dict)
104
+
105
+ def __post_init__(self) -> None:
106
+ self._setup_device_and_precision()
107
+ self._setup_paths()
108
+ self._ensure_output_paths_exist()
109
+ self.dataset_train_dataset_length = self._get_dataset_length(self.train_dataset_path)
110
+ self.trainer_max_steps = self._calculate_max_steps()
111
+
112
+ def _setup_device_and_precision(self) -> None:
113
+ if torch.cuda.is_available():
114
+ self.device = "cuda"
115
+ self.model_dtype = torch.bfloat16
116
+ elif torch.backends.mps.is_available():
117
+ self.device = "mps"
118
+ self.model_dtype = torch.float16
119
+ else:
120
+ self.device = "cpu"
121
+ self.model_dtype = torch.float32
122
+ logger.info(f"Execution environment: device={self.device}, dtype={self.model_dtype}")
123
+
124
+ def _setup_paths(self) -> None:
125
+ if self.workspace_path is None:
126
+ self.workspace_path = Path.cwd()
127
+ self.train_dataset_path = self.workspace_path / "outputs" / "preprocess" / "results" / "datasets" / "train_dataset.jsonl"
128
+ self.eval_dataset_path = self.workspace_path / "outputs" / "preprocess" / "results" / "datasets" / "eval_dataset.jsonl"
129
+ self.finetune_outputs_dir_path = self.workspace_path / "outputs" / "finetune"
130
+ self.trainer_checkpoints_dir_path = self.finetune_outputs_dir_path / "checkpoints"
131
+ self.trainer_model_merge_offload_folder_path = self.finetune_outputs_dir_path / "trainer_model_merge_offload_folder"
132
+ self.trainer_log_path = self.finetune_outputs_dir_path / "results" / "trainer_log.json"
133
+ self.trainer_plot_path = self.finetune_outputs_dir_path / "results" / "trainer_loss_plot.png"
134
+ self.lora_adapter_path = self.finetune_outputs_dir_path / "results" / "lora_adapter"
135
+ self.lora_model_path = self.finetune_outputs_dir_path / "results" / "lora_model"
136
+ logger.debug(f"Resolved workspace path to: {self.workspace_path}")
137
+
138
+ def _ensure_output_paths_exist(self) -> None:
139
+ paths = [
140
+ self.finetune_outputs_dir_path,
141
+ self.trainer_checkpoints_dir_path,
142
+ self.trainer_model_merge_offload_folder_path,
143
+ self.trainer_log_path,
144
+ self.trainer_plot_path,
145
+ self.lora_adapter_path,
146
+ self.lora_model_path
147
+ ]
148
+ for path in paths:
149
+ if not path.parent.exists():
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ logger.debug(f"Created parent directory: {path.parent}")
152
+ else:
153
+ logger.debug(f"Parent directory already exists: {path.parent}")
154
+
155
+ def _get_dataset_length(self, path: Path) -> int:
156
+ """
157
+ Calculates line count for streaming dataset progress estimation.
158
+ We need to do this because we load it as a streaming dataset iterator, which can only be iterated once.
159
+ """
160
+ if not path.exists():
161
+ raise FileNotFoundError( f"Training dataset not found at expected path: {path}. Ensure the dataset file exists before initializing the Config.")
162
+ count = 0
163
+ with open(path, "r", encoding="utf-8") as file:
164
+ for _ in file:
165
+ count += 1
166
+ logger.debug(f"Dataset length for '{path.name}': {count} lines")
167
+ return count
168
+
169
+ def _calculate_max_steps(self) -> int:
170
+ """
171
+ Calculates total steps based on effective batch size and dataset length.
172
+ Because we use streaming dataset iterators for efficiency, we cannot use num_train_epochs trainer class parameter directly.
173
+ Instead, we need to calculate max_steps and pass it to the trainer.
174
+ """
175
+ if self.dataset_train_dataset_length == 0:
176
+ logger.warning("Dataset length is 0; max_steps will be 0.")
177
+ return 0
178
+
179
+ effective_batch_size = (self.trainer_per_device_train_batch_size * self.trainer_gradient_accumulation_steps)
180
+ if effective_batch_size == 0:
181
+ raise ValueError("Effective batch size (batch_size * grad_accum) cannot be zero.")
182
+ steps_per_epoch = math.ceil(self.dataset_train_dataset_length / effective_batch_size)
183
+ max_steps = steps_per_epoch * self.trainer_num_train_epochs
184
+ logger.debug(f"Calculated training schedule: {max_steps} total steps ({steps_per_epoch} steps/epoch for {self.trainer_num_train_epochs} epochs)")
185
+ return max_steps
186
+
@@ -0,0 +1,59 @@
1
+
2
+ import logging
3
+
4
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
5
+ from transformers import AutoModelForCausalLM, BitsAndBytesConfig
6
+
7
+ from .config import Config
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def load_and_configure_lora_model(config: Config) -> AutoModelForCausalLM:
14
+ if config.device == "cuda":
15
+ bnb_config = BitsAndBytesConfig(
16
+ load_in_4bit=True,
17
+ bnb_4bit_quant_type="nf4", # nf4 (NormalFloat4) = optimal for LLM training, because llm weights follwo normal distribution
18
+ bnb_4bit_compute_type=config.model_dtype,
19
+ bnb_4bit_use_double_quant=True,
20
+ )
21
+ model = AutoModelForCausalLM.from_pretrained(
22
+ pretrained_model_name_or_path=config.model_name,
23
+ attn_implementation=config.model_attn_implementation,
24
+ quantization_config=bnb_config,
25
+ device_map="auto" # let bitsandbytes handle placement
26
+ )
27
+ model = prepare_model_for_kbit_training(model)
28
+ elif config.device == "mps":
29
+ model = AutoModelForCausalLM.from_pretrained(
30
+ pretrained_model_name_or_path=config.model_name,
31
+ attn_implementation=config.model_attn_implementation,
32
+ dtype=config.model_dtype
33
+ ).to("mps")
34
+ else:
35
+ model = AutoModelForCausalLM.from_pretrained(
36
+ pretrained_model_name_or_path=config.model_name,
37
+ attn_implementation=config.model_attn_implementation,
38
+ dtype=config.model_dtype
39
+ ).to("cpu")
40
+
41
+ logger.info(
42
+ f"Model: {config.model_name} | Device: {config.device} | "
43
+ f"Dtype: {config.model_dtype} | Attn: {config.model_attn_implementation}"
44
+ )
45
+
46
+ # forces the input to require gradients, ensuring the backward pass graph stays connected when using frozen base models with gradient checkpointing
47
+ if config.trainer_gradient_checkpointing:
48
+ model.enable_input_require_grads()
49
+
50
+ lora_config = LoraConfig(
51
+ lora_alpha=config.lora_alpha,
52
+ lora_dropout=config.lora_dropout,
53
+ r=config.lora_r,
54
+ bias=config.lora_bias,
55
+ task_type="CAUSAL_LM",
56
+ target_modules=config.lora_target_modules,
57
+ )
58
+ lora_model = get_peft_model(model, lora_config)
59
+ return lora_model