Added tool for finetuning mistral model based on training config

This commit is contained in:
Niels Geens
2025-01-24 11:05:23 +01:00
parent 1ee8cbc75c
commit 4fcb32e6a1
3 changed files with 276 additions and 148 deletions

View File

@@ -1,134 +0,0 @@
import json
import hashlib
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional
class FinetuneDatasetCreator:
"""Creates JSONL finetune dataset from iteration XML files"""
def __init__(
self,
iterations_dir: Path,
system_prompt_file: Path,
action_schema_file: Path,
output_file: Path
):
"""
Initialize the dataset creator
Args:
iterations_dir: Directory containing iteration XML files
system_prompt_file: Path to system prompt file
action_schema_file: Path to action schema file
output_file: Path where JSONL dataset will be written
"""
self.iterations_dir = Path(iterations_dir)
self.system_prompt_file = Path(system_prompt_file)
self.action_schema_file = Path(action_schema_file)
self.output_file = Path(output_file)
# Read and hash system prompt and action schema
self.system_prompt = self.system_prompt_file.read_text()
self.system_prompt_hash = self._calculate_hash(self.system_prompt)
self.action_schema = self.action_schema_file.read_text()
self.action_schema_hash = self._calculate_hash(self.action_schema)
def _calculate_hash(self, content: str) -> str:
"""Calculate SHA256 hash of content"""
return hashlib.sha256(content.encode()).hexdigest()
def _parse_iteration_file(self, file_path: Path) -> Optional[Dict]:
"""
Parse a single iteration XML file into a messages dictionary
Returns None if hashes don't match or parsing fails
"""
try:
tree = ET.parse(file_path)
root = tree.getroot()
# Verify hashes
if root.get('system_prompt_hash') != self.system_prompt_hash:
print(f"System prompt hash mismatch in {file_path}")
if root.get('action_schema_hash') != self.action_schema_hash:
print(f"Action schema hash mismatch in {file_path}")
# Get context and response
context = root.find('context').text
response = root.find('response').text
if not context or not response:
print(f"Missing context or response in {file_path}")
return None
# Create messages list
messages = [
{
"role": "system",
"content": self.system_prompt + self.action_schema
},
{
"role": "user",
"content": context
},
{
"role": "assistant",
"content": response
}
]
return {"messages": messages}
except Exception as e:
print(f"Error processing {file_path}: {str(e)}")
return None
def create_dataset(self) -> int:
"""
Create JSONL dataset from all valid iteration files
Returns:
Number of samples written to dataset
"""
sample_count = 0
# Create output directory if needed
self.output_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.output_file, 'w', encoding='utf-8') as f:
# Process each XML file in iterations directory
for xml_file in sorted(self.iterations_dir.rglob('*.xml')):
sample = self._parse_iteration_file(xml_file)
if sample:
json.dump(sample, f, ensure_ascii=False)
f.write('\n')
sample_count += 1
print(f"Created dataset with {sample_count} samples at {self.output_file}")
return sample_count
def main():
"""Command line interface"""
import argparse
parser = argparse.ArgumentParser(description='Create finetune dataset from iteration XML files')
parser.add_argument('iterations_dir', type=str, help='Directory containing iteration XML files')
parser.add_argument('system_prompt_file', type=str, help='Path to system prompt file')
parser.add_argument('action_schema_file', type=str, help='Path to action schema file')
parser.add_argument('output_file', type=str, help='Path for output JSONL dataset')
args = parser.parse_args()
creator = FinetuneDatasetCreator(
iterations_dir=args.iterations_dir,
system_prompt_file=args.system_prompt_file,
action_schema_file=args.action_schema_file,
output_file=args.output_file
)
creator.create_dataset()
if __name__ == '__main__':
main()

View File

@@ -299,21 +299,13 @@ The training configuration is defined in `/training/config.yaml`, which specifie
model: model:
system_prompt_path: "system_prompt.md" system_prompt_path: "system_prompt.md"
action_schema: "action_schema.xsd" action_schema: "action_schema.xsd"
params:
training_data:
conversations:
- path: "training/data/general/basic_interactions/"
description: "General user interactions"
hash: "abc123"
tasks:
- path: "training/data/code_generation/"
description: "Code writing examples"
hash: "def456"
training_params:
batch_size: 32
learning_rate: 1e-5 learning_rate: 1e-5
epochs: 3 epochs: 3
data:
- "training/clean_start/"
- "training/delete_indicated_entries/"
- "training/list_entries_to_delete/"
``` ```
## Continuous Operation ## Continuous Operation
@@ -341,4 +333,14 @@ This requires:
- Balance improvement with responsiveness - Balance improvement with responsiveness
- Monitor system resource usage - Monitor system resource usage
- Prevent training impact on user tasks - Prevent training impact on user tasks
- Clean up old data regularly - Clean up old data regularly
# TODO
- Fix training config
- Write training script
- implement stdio + auto mode
- Write setup script
- Explain challenge report card
- Document report card tool
- Write report card tool

View File

@@ -0,0 +1,260 @@
#!/usr/bin/env python3
from dataclasses import dataclass
from datetime import datetime
from dotenv import load_dotenv
from pathlib import Path
from typing import Dict, List, Optional, Set
import argparse
import hashlib
import json
import os
import requests
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
import yaml
@dataclass
class Config:
def __init__(self):
load_dotenv()
parser = argparse.ArgumentParser(description='Train SIA model using Mistral API')
parser.add_argument(
'--config',
type=Path,
default=os.getenv('SIA_TRAINING_CONFIG', 'training/config.yaml'),
help='Path to config file'
)
parser.add_argument(
'--model',
type=str,
default=os.getenv('SIA_MISTRAL_MODEL', 'mistral-large-latest'),
help='Base model for fine-tuning'
)
parser.add_argument(
'--api-key',
type=str,
default=os.getenv('SIA_MISTRAL_API_KEY'),
help='Mistral API key'
)
self.args = parser.parse_args()
@property
def config_path(self) -> Path:
return self.args.config
@property
def model(self) -> str:
return self.args.model
@property
def api_key(self) -> str:
return self.args.api_key
class FinetuneDatasetCreator:
def __init__(
self,
xml_files: Set[Path],
system_prompt_file: Path,
action_schema_file: Path,
output_file: Path
):
self.xml_files = xml_files
self.system_prompt_file = Path(system_prompt_file)
self.action_schema_file = Path(action_schema_file)
self.output_file = Path(output_file)
self.system_prompt = self.system_prompt_file.read_text()
self.system_prompt_hash = self._calculate_hash(self.system_prompt)
self.action_schema = self.action_schema_file.read_text()
self.action_schema_hash = self._calculate_hash(self.action_schema)
def _calculate_hash(self, content: str) -> str:
return hashlib.sha256(content.encode()).hexdigest()
def _parse_iteration_file(self, file_path: Path) -> Optional[Dict]:
try:
tree = ET.parse(file_path)
root = tree.getroot()
if root.get('system_prompt_hash') != self.system_prompt_hash:
print(f"System prompt hash mismatch in {file_path}")
return None
if root.get('action_schema_hash') != self.action_schema_hash:
print(f"Action schema hash mismatch in {file_path}")
return None
context = root.find('context').text
response = root.find('response').text
if not context or not response:
print(f"Missing context or response in {file_path}")
return None
return {
"messages": [
{
"role": "system",
"content": self.system_prompt + self.action_schema
},
{
"role": "user",
"content": context
},
{
"role": "assistant",
"content": response
}
]
}
except Exception as e:
print(f"Error processing {file_path}: {str(e)}")
return None
def create_dataset(self) -> int:
sample_count = 0
self.output_file.parent.mkdir(parents=True, exist_ok=True)
with open(self.output_file, 'w', encoding='utf-8') as f:
for xml_file in sorted(self.xml_files):
sample = self._parse_iteration_file(xml_file)
if sample:
json.dump(sample, f, ensure_ascii=False)
f.write('\n')
sample_count += 1
print(f"Created dataset with {sample_count} samples at {self.output_file}")
return sample_count
def find_xml_files(data_paths: List[Path]) -> Set[Path]:
xml_files = set()
for path in data_paths:
if not path.exists():
print(f"Error: Data path not found: {path}")
sys.exit(1)
xml_files.update(path.rglob('*.xml'))
return xml_files
def check_git_status(paths: list[Path]) -> str:
try:
for path in paths:
result = subprocess.run(['git', 'status', '--porcelain', str(path)],
capture_output=True, text=True)
if result.stdout.strip():
print(f"Error: Uncommitted changes in {path}")
print(result.stdout)
sys.exit(1)
result = subprocess.run(['git', 'rev-parse', 'HEAD'],
capture_output=True, text=True)
return result.stdout.strip()
except subprocess.CalledProcessError as e:
print(f"Git command failed: {e}")
sys.exit(1)
def create_combined_dataset(xml_files: Set[Path], config_data: dict, tmp_dir: Path) -> list:
tmp_file = tmp_dir / "dataset.jsonl"
creator = FinetuneDatasetCreator(
xml_files=xml_files,
system_prompt_file=config_data['model']['system_prompt_path'],
action_schema_file=config_data['model']['action_schema'],
output_file=tmp_file
)
creator.create_dataset()
with open(tmp_file) as f:
return [json.loads(line) for line in f]
def prepare_training_data(config: Config) -> tuple[list, dict, str]:
with open(config.config_path) as f:
config_data = yaml.safe_load(f)
data_paths = [Path(p) for p in config_data['data']]
xml_files = find_xml_files(data_paths)
paths = list(xml_files)
paths.append(config.config_path)
paths.append(Path(config_data['model']['system_prompt_path']))
paths.append(Path(config_data['model']['action_schema']))
commit_hash = check_git_status(paths)
with tempfile.TemporaryDirectory() as tmp_dir:
training_data = create_combined_dataset(xml_files, config_data, Path(tmp_dir))
train_params = {
'learning_rate': config_data['params']['learning_rate'],
'epochs': config_data['params']['epochs']
}
return training_data, train_params, commit_hash
def upload_file(api_key: str, file_path: Path) -> str:
url = "https://api.mistral.ai/v1/files"
headers = {
"Authorization": f"Bearer {api_key}"
}
files = {
"file": ("dataset.jsonl", open(file_path, "rb"), "application/jsonl"),
"purpose": (None, "fine-tune")
}
response = requests.post(url, headers=headers, files=files)
if response.status_code != 200:
print(f"Error uploading file: {response.text}")
sys.exit(1)
return response.json()["id"]
def main():
config = Config()
if not config.api_key:
print("Error: Mistral API key not found. Set SIA_MISTRAL_API_KEY environment variable.")
return 1
training_data, train_params, commit_hash = prepare_training_data(config)
model_name = f"sia_{commit_hash}"
# Create temp file and upload
with tempfile.NamedTemporaryFile(mode='w', suffix='.jsonl', delete=False) as f:
for sample in training_data:
json.dump(sample, f)
f.write('\n')
try:
file_id = upload_file(config.api_key, Path(f.name))
# Create fine-tuning job
headers = {
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json"
}
data = {
"model": config.model,
"training_files": [{"file_id": file_id, "weight": 1}],
"hyperparameters": train_params
}
response = requests.post(
"https://api.mistral.ai/v1/fine_tuning/jobs",
headers=headers,
json=data
)
if response.status_code != 200:
print(f"Error creating fine-tuning job: {response.text}")
return 1
job_id = response.json()["id"]
print(f"Started fine-tuning job: {model_name}")
print(f"Job ID: {job_id}")
print(f"Check status: curl -H 'Authorization: Bearer {config.api_key}' https://api.mistral.ai/v1/fine_tuning/jobs/{job_id}")
finally:
os.unlink(f.name)
return 0
if __name__ == "__main__":
exit(main())