OverviewOne loop, run three ways
Every experiment in the archive is the same loop. A large "teacher" model turns company documents into question-and-answer style records. A small open model (Llama 3.1 8B, then Gemma 2 9B) is loaded in 4-bit and trained with a LoRA adapter on those records. At inference a gate decides whether a question is on-topic before the model is allowed to answer. What changed over five months is the scale of the data, the toolchain used to train, and the mechanism used to gate.
How to read this guide
The archive is presented in three eras, each with a fixed colour that carries through the timelines, the diagrams and the charts. Within each era the chapters follow the order the work happened, and each ends with the lessons that carried forward. Cross-cutting chapters then collect the recipe, the results, the gating lineage, the data formats and a checklist for the next fine-tuning job.
Code is quoted verbatim from the archive with its file path and line range. Where a script is lost and only its W&B metadata survives, the text says so. Every number in the results chapter comes from a trainer_state.json, a W&B summary or a log line.
| Folder | Era | What it holds | Chapter |
|---|---|---|---|
| *.py, *.ipynb at top level | 1 | Smoke tests, twelve-genre generator, first trainer, embedding-gated inference | 1.1–1.5 |
| fine_tuned_llama3_qlora*, fine_tunned_llama3_1_qlora_ansh_v_* | 1 | Seven early adapters and their checkpoints | 1.6 |
| Ditillation/ | 1 | Teacher-written FAQ data, two trainers, refusal + keyword inference | 1.7 |
| GroQ/, Axolotl/, Langgraph/ | 1 | Groq generator, an unused Axolotl config, an unrelated LangGraph demo | 1.8 |
| Unsloth/ | 2 | Unsloth Colab template, Qwen and Hindi variants, six checkpoints | 2.1 |
| Torchtune/ | 2 | Plain Transformers trainer (no torchtune), four adapters, first keyword gate | 2.2 |
| FineTunning Pipeline/ | 2 | Generation, preprocessing, Ray Tune, SFTTrainer run, TF-IDF classifier | 2.3 |
| FINETUNING_ACCERLATE/ | 2 | Modular pipeline, BERT classifier, the device-map failure | 2.4 |
| Finetuning_Pipeline_30000/ | 3 | Bulk QwQ generation, the served Gemma adapter, FastAPI + Streamlit | 3.1, 3.6 |
| COMMERCIENT_CLAUD_FINETUNING/ | 3 | Axolotl YAML and DeepSpeed configs (never trained), vendored axolotl clone | 3.3 |
| COMMERCIENT_CLAUDE_FINETUNING/ | 3 | The reference trainer, 90 launch directories, the 117-hour run | 3.4 |
| COMMERCIENT_GEMMA_FINETUNING/ | 3 | Multi-customer CLI, row-wise generation, DuckDB store, Flask UI | 3.5 |
| axolotl/, Ditillation/thinc-9.1.1/, */unsloth_compiled_cache/ | — | Vendored third-party code, not part of the research | — |
| wandb/, */wandb/, */checkpoint-*, ray_results/, tensorboard_logs/ | — | Run logs and optimizer states; mined for the results chapter | Results |
TechniqueThe recurring recipe: QLoRA on a consumer GPU
Every trainer in the archive, from a forty-line script in February to an 893-line class in July, does the same five things. Understanding them once makes every folder readable.
- Quantise the base model to 4-bit so an 8B or 9B model fits in a 24 GB card with room for activations.
BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_quant_type="nf4", bnb_4bit_use_double_quant=True, bnb_4bit_compute_dtype=…). NF4 is a 4-bit data type shaped for normally distributed weights; double quantisation compresses the quantisation constants themselves; the compute dtype is what the matmuls run in. - Prepare it for k-bit training with
prepare_model_for_kbit_training, which casts layer norms to fp32, enables gradient checkpointing and makes the inputs require gradients so backprop can flow through frozen quantised layers. - Attach LoRA adapters with
LoraConfig(r, lora_alpha, target_modules, lora_dropout)andget_peft_model. Only the adapter matrices train; the base stays frozen. - Format records into one text field, either an Alpaca template or the tokenizer's chat template, and train with
Traineror trl'sSFTTrainer. - Save the adapter (a few megabytes to a few hundred), then reload it with
PeftModel.from_pretrained(base, adapter), optionallymerge_and_unload()for serving.
LoraConfig gets its own A and B. This is why the archive's adapter sizes jump by orders of magnitude when the target list changes.The stack in its most complete form
The June trainer in COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.py is the archive's reference implementation. Its model-loading block shows the recipe with every safety check the earlier eras lacked: compute dtype chosen from the config, k-bit preparation before LoRA, cache disabled for training, and special-token ids copied into the model config.
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch_dtype, # Use the determined torch_dtype here
bnb_4bit_use_double_quant=True,
)
logger.info("BitsAndBytes 4-bit quantization enabled.")
...
try:
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True,
torch_dtype=torch_dtype,
low_cpu_mem_usage=True
)
logger.info(f"Model {model_name} loaded successfully.")
# CRITICAL: Prepare model for k-bit training BEFORE applying LoRA
if bnb_config is not None:
logger.info("Preparing model for k-bit training...")
model = prepare_model_for_kbit_training(model)
logger.info("Model prepared for k-bit training.")
# Moved to TrainingArguments for SFTTrainer
# model.gradient_checkpointing_enable()
model.config.use_cache = False # Important for training
# Ensure tokenizer settings are reflected in model config for compatibility
if tokenizer.pad_token_id is not None:
model.config.pad_token_id = tokenizer.pad_token_id
if tokenizer.bos_token_id is not None:
model.config.bos_token_id = tokenizer.bos_token_id
if tokenizer.eos_token_id is not None:
model.config.eos_token_id = tokenizer.eos_token_id lora_config = LoraConfig(
r=self.config["lora_r"],
lora_alpha=self.config["lora_alpha"],
target_modules=target_modules,
lora_dropout=self.config["lora_dropout"],
bias="none",
task_type=TaskType.CAUSAL_LM,
)
model = get_peft_model(model, lora_config)
callbacks = []
if val_dataset:
early_stopping = EarlyStoppingCallback(
early_stopping_patience=self.config.get("early_stopping_patience", 3),
early_stopping_threshold=self.config.get("early_stopping_threshold", 0.001),
)
callbacks.append(early_stopping)
if self.is_cuda_available:
callbacks.append(self.EmptyCacheCallback())
callbacks.append(self.MemoryMonitoringCallback())
trainer = SFTTrainer(
model=model,
train_dataset=train_dataset, # list of {"messages": [...]} — trl 0.12 applies the chat template
eval_dataset=val_dataset,
args=training_args,
callbacks=callbacks,
tokenizer=tokenizer,
max_seq_length=self.config.get("max_seq_length", 1024),
)
train_result = trainer.train()
trainer.model.save_pretrained(str(output_dir / "final_adapter"))
tokenizer.save_pretrained(str(output_dir / "tokenizer"))Every LoRA configuration in the archive
Reading the saved adapter_config.json files rather than the scripts gives the ground truth of what actually trained. Two things stand out: the target list drifted between "q and v only" and "all seven projections" depending on whether target_modules was passed at all, and the two adapters that included a vocabulary projection are one to two orders of magnitude larger than everything else.
| Adapter directory | Date | Base model | r | α | Target modules | Size |
|---|---|---|---|---|---|---|
| fine_tuned_llama3_qlora, …_ansh_v, …_v_4_instruct, …_v_5_TAG, …_v_6_TAG_JSON | 25–27 Feb | Llama 3 / 3.1 8B | 8 | 32 | q_proj, v_proj | 13.6 MB |
| fine_tuned_llama3_qlora_ansh_v_7 | 27 Feb | Llama-3.1-8B | 10 | 16 | 7 projections + lm_head | 1.16 GB |
| Ditillation/Commercient_faq_fine_tune_v_1 | 28 Feb | Llama-3.1-8B | 8 | 32 | q_proj, v_proj | 13.6 MB |
| Ditillation/llama3_8b_faq_finetuned | 3 Mar | Meta-Llama-3-8B | 16 | 32 | 7 projections | 168 MB |
| Unsloth/Lamma_3_1_8B_Qlora_Co_Unsolth_Version_1_1 | 11 Mar | Llama-3.1-8B (Unsloth 4-bit) | 16 | 16 | 7 projections | 168 MB |
| Unsloth/Qwen_2_5_7B_Qlora_Co_Unsolth_Version_1_1 | 11 Mar | Qwen2.5-7B (Unsloth 4-bit) | 16 | 16 | 7 projections | 161 MB |
| Torchtune/fine_tuned_llama_torchtune, _V_1 | 27 Mar | Llama-3.1-8B | 8 | 16 | q_proj, v_proj (peft default) | 13.6 MB |
| Torchtune/…_V_2 / _V_3 | 27 Mar | Llama-3.1-8B | 10 / 12 | 32 | q_proj, v_proj (peft default) | 17.1 / 20.5 MB |
| FineTunning Pipeline/llama3-8b-finetuned | 14 Apr | Meta-Llama-3-8B | 64 | 128 | q, k, v, o | 218 MB |
| FineTunning Pipeline/Fine_tunned_ALL_SYNC_Data, fine_tuned_model, Commercient_fine_tuned_model | 17–22 Apr | Llama-3.1-8B | 12 | 32 | q_proj, v_proj (peft default) | 20.5 MB |
| Finetuning_Pipeline_30000/fine_tuned_gemma_v_2 (served) | 14 May | unsloth/gemma-2-9b-it | 16 | 32 | 7 projections | 216 MB |
| Finetuning_Pipeline_30000/All_Commercient_Data_Finetune_Gemma_version_1 | 4 Jun | unsloth/gemma-2-9b-it | 8 | 16 | 7 projections (its hyperparameters.json claims r 16) | 108 MB |
| COMMERCIENT_CLAUDE_FINETUNING/…/20250606_040433/error_checkpoint | 6 Jun | google/gemma-2-9b-it | 32 | 64 | 7 projections + embed_tokens, lm_head | 7.8 GB |
| COMMERCIENT_CLAUDE_FINETUNING/…/20250606_072330/final_adapter | 11 Jun | google/gemma-2-9b-it | 32 | 64 | 7 projections | 432 MB |
The hyperparameters that converged
By June the training configuration had settled into the values below. They are a sound starting point for QLoRA on a 9B instruction-tuned model with roughly 100k short chat records on a single 24 GB GPU.
- quantisation
- 4-bit NF4, double quant, fp16 compute (bf16 works on Ampere and later and avoids fp16 overflow)
- LoRA
- r 32, α 64, dropout 0.1, targets q_proj k_proj v_proj o_proj gate_proj up_proj down_proj
- optimiser
- adamw_torch, lr 1e-5, weight decay 0.01, max grad norm 1.0, cosine schedule with 200 warmup steps
- batch
- per-device 2 × gradient accumulation 10 = effective 20 (the config comment claiming 64 was stale)
- sequence
- max_seq_length 1024 (2048 and 4096 ran out of memory at batch 4), group_by_length on, gradient checkpointing on
- evaluation
- eval and save every 100 steps, keep 5 checkpoints, load best on eval_loss, early stopping patience 5 / threshold 5e-4
- data
- records as
{"messages": [user, assistant]}, formatted by the tokenizer's chat template inside SFTTrainer; no completion-only masking
Two settings deserve a second look next time. The learning rate of 1e-5 is low for LoRA (2e-4 is the usual starting point; the June runs that tried 2e-4 were killed before they could be compared, and the 2e-2 run that diverged was a typo). And evaluating 29,917 validation samples every 100 steps consumed roughly half of the 117-hour run; a validation set of a few hundred samples would have given the same early-stopping signal.
What an adapter directory contains, and how to use it
adapter_config.json: base model name, r, α, dropout, target modules, peft version. This is the file to trust over any folder name or hand-written notes.adapter_model.safetensors: the A and B matrices (plus the full head if you targeted it).- Tokenizer files, if the script saved them:
tokenizer.json,tokenizer_config.json,special_tokens_map.json. The Instruct tokenizers carry the chat template here. - In
checkpoint-N/subfolders:trainer_state.json(the loss history that made the charts in this guide possible),training_args.bin,optimizer.pt,scheduler.pt. README.md: in every single directory in the archive, the untouched peft template. Fill it in next time; it costs one minute and saves an afternoon.
def load_generation_model(base_model_name: str, adapter_path: str):
"""Loads the fine-tuned Gemma-9B model for text generation."""
if not os.path.exists(adapter_path):
raise FileNotFoundError(f"Adapter path '{adapter_path}' not found.")
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_use_double_quant=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_compute_dtype=torch.float16,
)
base_model = AutoModelForCausalLM.from_pretrained(
base_model_name,
quantization_config=bnb_config,
device_map="auto",
trust_remote_code=True
)
tokenizer = AutoTokenizer.from_pretrained(adapter_path, trust_remote_code=True)
if tokenizer.pad_token is None:
tokenizer.pad_token = tokenizer.eos_token
model = PeftModel.from_pretrained(base_model, adapter_path)
print("Merging adapters...")
model = model.merge_and_unload()
model.eval()
return model, tokenizerEra 1 · 17 Feb – 25 Mar 2025 · top-level scripts, Ditillation/, GroQ/, Axolotl/First contact: one seed sentence, one 8B model
Five weeks of learning the QLoRA loop by hand: generate text with a 70B teacher, attach a LoRA adapter to a 4-bit Llama 3.1 8B with the Hugging Face Trainer, and refuse off-topic questions at inference. Every dataset in this era has between 2 and 180 examples and every run has 3 to 69 optimizer steps, so no adapter learned real domain knowledge. What the era produced instead is the recipe that every later pipeline inherits.
- 17–20 FebSmoke tests. Load
meta-llama/Llama-3.1-8B-Instructin fp16 and callgenerateon a trivial prompt. - 21–24 FebSynthetic data v0. Twelve LangChain prompt chains against a self-hosted vLLM Llama-3.3-70B turn one seed sentence into a text corpus, split into 256 sentences with nltk.
- 25 FebFirst QLoRA runs. Four W&B runs log
loss 0.0, grad_norm nan. A variant withpaged_adamw_32bitand batch 2 produces the first two adapters. - 26 FebGating v0. Cosine similarity between the question and the seed sentence decides whether the model may answer. Adapters v_4 (Instruct base) and v_5 (tagged text).
- 27 FebStructured records. A regex parser turns filtered sentences into
{instruction, output, tag}JSON, but only 2 records survive. v_6 trains on them for 3 steps. v_7 widens LoRA to every projection pluslm_head. - 28 Feb – 5 Mar"Distillation". The teacher writes Question/Answer pairs from a real source document. Two more adapters, the first all-projection LoRA, and refusal by keyword rejection sampling.
- 5 MarAxolotl drafted. A YAML config that was never valid Axolotl schema and never ran.
- 6–25 MarGroq as generator. Generation moves to Groq's hosted
llama-3.3-70b-versatile, source text is chunked, and the records converge on the Alpacainstruction/input/outputshape.
1.1 Synthetic data v0: twelve genres from one sentence
The whole first corpus derives from a single sentence: "Commercient integrates CRM and ERP systems, allowing businesses to streamline operations and enhance efficiency." Twelve functions each build a LangChain PromptTemplate, pipe it into the internal LlamaWrapper client (an OpenAI-compatible vLLM server running Llama-3.3-70B-Instruct), and ask for a different genre: summary, FAQ, professional rewrite, did-you-know facts, analog story, instructional guide, podcast transcript, business use case, AI use case, quick tips, RAG chatbot questions, and an "everything you wanted to know" overview.
Each chain is a small LCEL pipeline. The FAQ and instructional variants below show the pattern: a template with a trailing cue ("FAQs:", "Instructional Guide:") so the model continues in the desired format, then post-processing that strips symbols and prepends a bracket tag.
from langchain import PromptTemplate, LLMChain
from llama_wrapper import LlamaWrapper
from langchain.prompts import PromptTemplate
from langchain.chains import LLMChain
import re
def generate_summary(text):
print("Generate summary started \n")
"""Generates a summary of the input text using Langchain and Hugging Face."""
llm = LlamaWrapper(
api_host="http://<internal-vllm-host>:8111", # real host redacted for publication
api_key="<redacted>", # hardcoded in the original; redacted here
model="meta-llama/Llama-3.3-70B-Instruct",
temperature=0.5,
max_tokens=500
)
template = """Write a concise summary of the following text:
Text: {text}
Summary:"""
prompt = PromptTemplate(template=template, input_variables=["text"])
summary_chain = prompt | llm
return summary_chain.invoke({"text": text}) template = """
Generate a clear and structured instructional guide on the topic below.
The instruction must:
- Provide a step-by-step breakdown
- Be easy to follow and precise
- Avoid repetition and unnecessary details
- Use bullet points or numbering for clarity
**Topic:** {prompt_text}
**Number of Steps:** {num_steps}
**Instructional Guide:**
"""
prompt = PromptTemplate(template=template, input_variables=["prompt_text", "num_steps"])
instruction_chain = prompt | llm
raw_output = instruction_chain.invoke({"prompt_text": prompt_text, "num_steps": num_steps}).strip()
clean_instruction = re.sub(r'[^A-Za-z0-9.,\'"!?;:\s]', '', raw_output) # Remove random symbols
clean_instruction = re.sub(r'\b(\w+)\s+\1\b', r'\1', clean_instruction) # Remove repeated words
# **Tagging the Output**
tagged_instruction = f"[Official Commercient Content]\n\n{clean_instruction}"
return tagged_instructionThe character class [^A-Za-z0-9.,'"!?;:\s] deletes %, $, -, parentheses, asterisks and hash marks, so "family-run" became "familyrun" and every Markdown heading lost its structure. The second regex collapses any doubled word, including legitimate ones. Downstream parsers then had to guess at formatting the generator had already produced correctly. The durable fix, adopted three weeks later, is to ask the teacher for structured JSON and validate it rather than to clean free text afterwards.
1.2 The first QLoRA trainer
The trainer for the very first adapters is lost, but fine_tunning_method_2json.py is its direct descendant and the template for every hand-rolled trainer in the archive. It has five moves: load the JSON, tokenize with labels = input_ids, load the base model in 4-bit NF4, wrap it in a LoRA adapter, and hand everything to Trainer with a causal-LM collator.
model_name = "meta-llama/Meta-Llama-3.1-8B"
tokenizer = AutoTokenizer.from_pretrained(model_name)
tokenizer.pad_token = tokenizer.eos_token # Set padding token
# Tokenization function (Set up labels for loss calculation)
def tokenize_function(examples):
# Check if the input is "instruction" and "output" or just "text"
if "instruction" in examples and "output" in examples:
# Combine instruction and output for training
texts = [f"{instruction}\n{output}" for instruction, output in zip(examples["instruction"], examples["output"])]
elif "text" in examples:
texts = examples["text"]
else:
raise ValueError("Dataset must contain either 'instruction' and 'output' keys, or a 'text' key.")
tokenized = tokenizer(texts, padding="max_length", truncation=True, max_length=512)
tokenized["labels"] = tokenized["input_ids"].copy() # Labels needed for loss computation
return tokenized
dataset = Dataset.from_list(dataset)
tokenized_dataset = dataset.map(tokenize_function, batched=True)# Load LLaMA 3.1 model in 4-bit
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16
)
model = AutoModelForCausalLM.from_pretrained(
model_name,
quantization_config=bnb_config,
device_map="auto"
)
print(model_name)
# Apply QLoRA
lora_config = LoraConfig(
r=8, # LoRA rank
lora_alpha=32,
target_modules=["q_proj", "v_proj"],
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM",
base_model_name_or_path=model_name # Fix PEFT warning
)
model = get_peft_model(model, lora_config)
model.print_trainable_parameters()# Use Data Collator to avoid unnecessary padding
data_collator = DataCollatorForLanguageModeling(tokenizer, mlm=False)
# Training Arguments (2 GPUs)
training_args = TrainingArguments(
output_dir="./fine_tunned_llama3_1_qlora_ansh_v_6_TAG_JSON",
per_device_train_batch_size=2,
gradient_accumulation_steps=4,
evaluation_strategy="no",
save_strategy="epoch",
save_total_limit=2,
logging_steps=10,
learning_rate=2e-4,
weight_decay=0.01,
num_train_epochs=3,
bf16=True, # Use bfloat16
fp16=False,
optim="paged_adamw_32bit",
ddp_find_unused_parameters=False,
report_to="none"
)
# Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=tokenized_dataset,
data_collator=data_collator
)
trainer.train()
model.save_pretrained("./fine_tunned_llama3_1_qlora_ansh_v_6_TAG_JSON")
tokenizer.save_pretrained("./fine_tunned_llama3_1_qlora_ansh_v_6_TAG_JSON")Three details in this file quietly shaped every run that followed. The tokenizer's pad token is set to the EOS token. Labels are a copy of the input ids, so the loss is computed on the instruction text as well as the answer. And DataCollatorForLanguageModeling rebuilds the labels itself, setting -100 wherever the input id equals the pad id. Because pad and EOS are the same id, the model never receives a loss signal for stopping.
The four W&B runs of the lost first trainer all logged loss: 0.0, grad_norm: nan, eval_loss: nan from the first epoch. Their config used paged_adamw_8bit with per-device batch 4. The two adapters that survive from the same day came from a variant with paged_adamw_32bit, batch 2 and bf16, so the fix was almost certainly optimizer precision. There is no code to prove causality, which is itself the lesson: log the exact script with each run.
1.3 Reloading an adapter correctly
The pattern for using a saved adapter appears here for the first time and stays constant through the whole archive: load the base in 4-bit with the same quantization config, then attach the adapter directory with PeftModel.from_pretrained. The only things that vary later are whether the adapters are merged for serving and which chat template wraps the prompt.
# Define fine-tuned model path
fine_tuned_model_path = "fine_tuned_llama3_qlora_ansh_v"
# Load tokenizer
tokenizer = AutoTokenizer.from_pretrained(fine_tuned_model_path)
tokenizer.pad_token = tokenizer.eos_token # Ensure proper padding
# Load the model in 4-bit precision using bitsandbytes
bnb_config = BitsAndBytesConfig(
load_in_4bit=True,
bnb_4bit_quant_type="nf4",
bnb_4bit_use_double_quant=True,
bnb_4bit_compute_dtype=torch.float16
)
# Load base model with quantization
base_model = AutoModelForCausalLM.from_pretrained(
"meta-llama/Meta-Llama-3-8b", # meta-llama/Meta-Llama-3-8B
quantization_config=bnb_config,
device_map="auto" # Automatically distributes across multiple GPUs
)
# Load fine-tuned QLoRA adapters
model = PeftModel.from_pretrained(base_model, fine_tuned_model_path)
model.eval()
print("✅ Fine-tuned model loaded successfully across multiple GPUs!")Two traps visible here: the lowercase Meta-Llama-3-8b repo id is a different cache key from Meta-Llama-3-8B, which triggered a fresh 16 GB download in the reload notebook; and the directory named fine_tuned_llama3_qlora actually holds a Llama 3.1 adapter. Trust adapter_config.json, never the folder name.
1.4 Gating v0: cosine similarity to the seed
The idea that the assistant must refuse off-topic questions is present from the very first inference script. Version zero embeds the question and the seed sentence with all-mpnet-base-v2 (mean-pooled through a raw AutoModel, not the sentence-transformers wrapper), and only calls the LLM when cosine similarity reaches 0.5.
embedding_model_name = "sentence-transformers/all-mpnet-base-v2" # Choose a suitable model
embedding_model = AutoModel.from_pretrained(embedding_model_name).to("cuda") #move embedding model to gpu
embedding_tokenizer = AutoTokenizer.from_pretrained(embedding_model_name)
def get_embedding(text):
"""
Generates an embedding for the given text using the Sentence Transformer model.
"""
input_ids = embedding_tokenizer(text, return_tensors="pt", truncation=True, padding=True).to("cuda") #move input to gpu
with torch.no_grad():
outputs = embedding_model(**input_ids)
embeddings = outputs.last_hidden_state.mean(dim=1) # Mean pooling
return embeddings.cpu().numpy() #move embedding to cpu
source_document = "Commercient integrates CRM and ERP systems, allowing businesses to streamline operations and enhance efficiency."
source_embedding = get_embedding(source_document)
def generate_response(prompt, similarity_threshold=0.5):
"""
Generates a response based on the prompt. Calculates the cosine similarity
between the prompt and the source document.
"""
prompt_embedding = get_embedding(prompt)
similarity_score = cosine_similarity(prompt_embedding, source_embedding)[0][0]
print(similarity_score)
if similarity_score >= similarity_threshold:
inputs = tokenizer(prompt, return_tensors="pt").to("cuda") # Move to GPU
with torch.no_grad():
output = model.generate(**inputs, max_new_tokens=200)
return tokenizer.decode(output[0], skip_special_tokens=True)
else:
return "I am designed to answer questions specifically related to Commercient's integration of CRM and ERP systems. Your query falls outside of that scope."This is the direct ancestor of the Pinecone RAG gate in the production API five months later. The difference is the reference set: one sentence here, a whole vector index of company documents there. The full lineage of gating approaches is traced in its own chapter.
1.5 Tag conditioning, and the regex parser that produced two records
On 27 Feb two things happened. First, the gate was moved into the prompt: a long system instruction told the model to answer only content tagged "official commercient content" and to "remain silent" otherwise. Second, a notebook tried to turn the free-text corpus into structured training records: keep sentences with cosine similarity of at least 0.5 to the seed, write them out, then regex-extract question/answer pairs, facts and steps.
def clean_text(text):
return re.sub(r"[^a-zA-Z0-9\s:]", "", text)
with open('Filtered_Synthetic_data.txt','w') as f:
sentences = nltk.sent_tokenize(str(results)) # str() of a Python list: "\n" becomes the two characters \ and n
for i in sentences:
f.write(f"{clean_text(i)} \n") # clean_text strips the backslash and leaves a stray "n"
def parse_data(filepath, commercient_tag="[COM_SPECIAL]"):
data = []
with open(filepath, 'r', encoding='utf-8') as f:
text = f.read()
def tag_content(content, is_commercient=True):
return f"{commercient_tag} {content}" if is_commercient else content
# 1. Parse Q&A Pairs (note the "\nnA" — the regex was written to match the corrupted text)
qa_pairs = re.findall(r"Q(\d+): (.*?)\nnA(\d+): (.*?)(?=\nQ|$)", text, re.DOTALL)
for q_num, question, a_num, answer in qa_pairs:
is_commercient = "Commercient" in question
data.append({
"instruction": tag_content(question.strip(), is_commercient),
"output": tag_content(answer.strip(), is_commercient),
"tag": "official commercient content" if is_commercient else "general"
})Only two records matched, each swallowing dozens of lines because the lookahead never fired. training_data.json is a 25 KB file containing exactly two examples, and adapter v_6 trained on it for three optimizer steps. The chain of compensating bugs is instructive: a list serialized with str(), a regex "cleaner" that strips the backslashes, and parsers hand-fitted to the resulting nn artefacts. Structured output from the generator removes the entire chain.
1.6 The adapter progression, v through v_7
Seven adapters were saved in three days. Reading them in order shows the data format moving from "language model on a text blob" toward "instruction/response pairs", which is where the Ditillation FAQ records land the following day.
| Adapter | Base model | LoRA | Steps | Training data | Size | What changed |
|---|---|---|---|---|---|---|
fine_tuned_llama3_qlora | Llama-3.1-8B | r 8 · α 32 · q,v | 33 | ~90 raw sentences | 13.6 MB | First adapter that trained without NaN |
…_ansh_v | Meta-Llama-3-8B | r 8 · α 32 · q,v | 33 | same | 13.6 MB | Llama 3 base for comparison |
…_v_4_instruct | Llama-3.1-8B-Instruct | r 8 · α 32 · q,v | 33 | same | 13.6 MB | Instruct base, but no chat template used |
…_v_5_TAG | Llama-3.1-8B | r 8 · α 32 · q,v | 69 | ~180 tagged sentences | 13.6 MB | Bracket tags in the text, paired with the tag-conditioned prompt |
…_v_6_TAG_JSON | Llama-3.1-8B | r 8 · α 32 · q,v | 3 | 2 JSON records | 13.6 MB | First structured records, from the regex parser |
…_ansh_v_7 | Llama-3.1-8B | r 10 · α 16 · all 7 projections + lm_head | 60 | 140 records, 10 epochs | 1.16 GB | First wide LoRA; targeting lm_head saved the whole 1.05 GB head |
When lm_head or embed_tokens appears in target_modules, peft sets save_embedding_layers=True and writes the full [128256 × 4096] bf16 head into the adapter file. The LoRA tensors themselves are a few megabytes. Unless you intend to train the vocabulary projection, keep the target list to the attention and MLP projections.
1.7 "Distillation": teacher-written FAQs from a real document
The Ditillation/ folder is not knowledge distillation in the technical sense (no logits are transferred). It is the moment the pipeline abandoned the twelve-genre text soup for one format: the 70B teacher reads a real Commercient source document and writes Question:/Answer: pairs. This prompt was reused verbatim in the Groq scripts and its guidelines section survives, reworded, in the production generators.
template = """You are an expert at generating diverse and insightful Frequently Asked Questions (FAQs) from a given document. Your goal is to create {num_pairs} FAQ pairs that cover a wide range of topics, including common questions, edge cases, and questions that require critical thinking. Focus on generating creative and thought-provoking questions, and provide *detailed and comprehensive* answers based on the document. Avoid simple, obvious questions. Prioritize diversity and depth in the questions and answers. Pay special attention to potential misunderstandings or areas where clarification would be beneficial. Aim for answers that are multiple sentences long and provide thorough explanations.
Here are some guidelines:
* **Diversity:** Cover various aspects of the topic discussed in the document. Consider different user perspectives and potential use cases.
* **Creativity:** Go beyond the surface level. Formulate questions that require deeper understanding and synthesis of information. Think about 'what if' scenarios and potential problems.
* **Clarity:** Ensure both the question and answer are clear, concise, and easy to understand. Avoid jargon unless necessary, and if used, explain it.
* **Accuracy:** All answers must be factually correct and based on the provided document. If the document does not provide an answer, state that the answer is not available in the provided document.
* **Format:** Present each FAQ pair in the following format:
Question: [The question]
Answer: [The detailed answer]
Document: {text}
FAQs:"""The second trainer in this folder, demo_FT.py, is the first appearance of the LoRA shape the whole archive later standardises on: all seven projections, alpha equal to twice the rank, cosine schedule with warmup, and prepare_model_for_kbit_training before wrapping.
model = prepare_model_for_kbit_training(model)
# Configure LoRA
config = LoraConfig(
r=LORA_RANK,
lora_alpha=2 * LORA_RANK,
lora_dropout=0.2,
bias="none",
task_type="CAUSAL_LM",
target_modules=[
"q_proj",
"k_proj",
"v_proj",
"o_proj",
"gate_proj",
"up_proj",
"down_proj",
"linear"
] # Adjust target modules based on Llama 3 architecture if needed
)
model = get_peft_model(model, config)
model.print_trainable_parameters() # Print the number of trainable parameters
# ----------------------- Data Collator -----------------------
data_collator = DataCollatorForLanguageModeling(tokenizer, mlm=False, pad_to_multiple_of=8, return_tensors="pt")The 39 FAQ answers in faq.json each end with a literal ###END###. That sentinel is a workaround for the masked-EOS problem in the diagram above: since the model could not learn the real end-of-sequence token, the data taught it a visible one instead.
Inference in this folder introduced the third gating idea: a prompt-engineered refusal sentence plus rejection sampling. The model is sampled up to three times; an answer is accepted if it contains the refusal phrase or any of about thirty domain keywords.
def is_related_to_data(answer, keywords):
answer = answer.lower()
for keyword in keywords:
if keyword.lower() in answer:
return True
return False
def generate_answer_with_rejection(model, tokenizer, question, keywords, max_attempts=3):
for _ in range(max_attempts):
answer = generate_answer(model, tokenizer, question) # Use the prompt-engineered function
if "i am sorry" in answer.lower(): # Check for the "out of scope" phrase
return "I am sorry, I am not trained to answer question outside of my knowledge domain"
if is_related_to_data(answer, keywords):
return answer
return "I am sorry, I am not trained to answer question outside of my knowledge domain" # Default if all attempts failFiltering the answer rather than the question lets the model hallucinate first and be caught second, and three sampled attempts triple the latency. Generic keywords such as "sales" and "products" make the filter permissive. Still, the notebook shows it refusing "What is Cricket?" and "capital of india", which is the first recorded success of any gate in the archive.
1.8 Groq as the generator, and the Alpaca shape
From 6 March the teacher moved to Groq's hosted llama-3.3-70b-versatile through langchain_groq.ChatGroq, with the API key read from a .env file for the first time. Source text is chunked with textwrap.wrap at 4000 characters and each chunk is asked for 25 pairs. The output shape is the important part: by 25 March the records use instruction / input / output, the Alpaca layout that every later dataset in the archive uses.
llm = ChatGroq(
model_name="llama-3.3-70b-versatile", # Specify the Groq model
temperature=0.6, # Increased temperature for more creativity
max_tokens=2048, # Increased max_tokens to allow for longer, more detailed answers
groq_api_key=GROQ_API_KEY # Use the API key from .env
)
def create_json_from_faq(faq_text, tag="official commercient content"):
faq_pairs = []
pairs = faq_text.split("Question:") # Split the text by question to extract the content
for pair in pairs[1:]: # Skip the first empty element
try:
question, answer = pair.split("Answer:")
faq_pairs.append({
"question": question.strip(),
"input": "Official Commercient Content",
"output": answer.strip()
})
except ValueError as e:
print(f"Error parsing FAQ pair: {e}, Pair content: {pair}") # Debugging print
continue
return json.dumps(faq_pairs, indent=4)textwrap.wrap collapses paragraph structure and splitting on literal Question: markers leaks list numbering into the previous answer. The production generators replace both with RecursiveCharacterTextSplitter and a request for a JSON list.
What Era 1 taught
Ask the teacher for structure, not prose. Free text plus regex cleaning plus hand-fitted parsers produced two training examples from a day's work. A JSON list with a fixed schema produced 247 in one run three weeks later.
SyntheticData_to_Embeddings.ipynb · GroQ/Synthetic_data_generation_json.pyA seed sentence is not a knowledge source. Everything generated from it was invented. The moment a real document (Ditillation/Source.txt) was used, the data became usable.
Pad = EOS silently masks the stop signal. Every trainer in this era set pad_token = eos_token and used the LM collator, so no model could learn to stop. The ###END### sentinel and the max_new_tokens caps were compensations.
Match precision settings across the stack. bf16=True in the trainer with bnb_4bit_compute_dtype=float16, or a bf16 model with an fp16 trainer, appear in three scripts. Pick one compute dtype and use it everywhere.
Keep lm_head out of target_modules unless you mean to ship a gigabyte. And prefer an explicit BitsAndBytesConfig over the load_in_4bit=True shorthand, which defaults the compute dtype to fp32 and slows training.
Use the chat template of an Instruct model. v_4 trained on Llama-3.1-8B-Instruct with raw strings and no apply_chat_template, throwing away the base model's instruction tuning.
Evaluate something. Every run set eval_strategy="no"; the one that evaluated logged NaN. Manual spot checks of two prompts were the only evaluation until June.
Move secrets out of code from day one. The internal LLM host and key are hardcoded in about fourteen places in this era; the Groq key correctly moved to .env on 6 March.
Era 2 · 5 Mar – 25 Jun 2025 · Unsloth/, Torchtune/, FineTunning Pipeline/, FINETUNING_ACCERLATE/Framework shopping: Unsloth, plain Transformers, Ray Tune, Accelerate
Four attempts at the same idea with four toolchains. Unsloth proved that QLoRA on 250 records fits on a free Colab T4 and also demonstrated refusal collapse. A folder called Torchtune never imported torchtune and instead nailed down the plain Transformers recipe. The FineTunning Pipeline was the first end-to-end run: multi-format generation, a preprocessor, a 968-step SFT run and a TF-IDF relevance classifier. FINETUNING_ACCERLATE refactored everything into modules, scaled the data to 149,583 records and switched to Gemma 2 9B, then spent a day on one multi-GPU error.
- 5–6 Mar49 FAQ pairs become Alpaca records.
Torchtune/Dataset.jsonis converted to{instruction, input, output}with a constant input tag; the set grows to 247 records, 85 of them refusals. - 10–13 MarUnsloth. Official Llama-3.1 Alpaca Colab on a Tesla T4, then local runs on Llama 3.1 8B, Qwen2.5-7B and a bilingual English–Hindi set. Loss falls to 0.008 in 60 steps; the model refuses on-topic questions.
- 27 MarPlain Transformers, four runs in 75 minutes. r 8 → 10 → 12, epochs 5 → 10 → 15, loss 6.4 → 0.013. First keyword gate and first correct
PeftModelload. - 7–22 AprFineTunning Pipeline. Groq generates FAQ, podcast, story and instructional data from a real implementation guide; a preprocessor normalises four schemas to tagged text; ~20 aborted launches, a Ray Tune sweep that never produced a trial, then trl
SFTTrainerfor 968 steps. - 24–30 AprTF-IDF question classifier. 10,076 generated questions train a logistic-regression relevance gate; the final inference script comments it out again in favour of prompt rules.
- 30 May – 2 JunFINETUNING_ACCERLATE. Modular pipeline with a config module, BERT classifier, early stopping and Accelerate. Gemma-2-9B-it on 119,665 training records: every launch dies on the bitsandbytes "different device" error.
- 12 JunHand-off. A Gradio app points at the successor pipeline's adapter. The design carries into Era 3.
2.1 Unsloth: the Alpaca template, packing, and refusal collapse
The Unsloth scripts are the official Llama-3.1 Alpaca notebook with the data cell swapped. FastLanguageModel.from_pretrained loads a pre-quantised 4-bit checkpoint, get_peft_model attaches LoRA with rank 16 on all seven projections, and trl's SFTTrainer trains on a text column with packing=True.
max_seq_length = 2048 # Choose any! We auto support RoPE Scaling internally!
dtype = None # None for auto detection. Float16 for Tesla T4, V100, Bfloat16 for Ampere+
load_in_4bit = True # Use 4bit quantization to reduce memory usage. Can be False
model, tokenizer = FastLanguageModel.from_pretrained(
model_name = "unsloth/Meta-Llama-3.1-8B",
max_seq_length = max_seq_length,
dtype = dtype,
load_in_4bit = load_in_4bit,
)
model = FastLanguageModel.get_peft_model(
model,
r = 16, # Choose any number > 0 ! Suggested 8, 16, 32, 64, 128
target_modules = ["q_proj", "k_proj", "v_proj", "o_proj",
"gate_proj", "up_proj", "down_proj",],
lora_alpha = 16,
lora_dropout = 0, # Supports any, but = 0 is optimized
bias = "none", # Supports any, but = "none" is optimized
# [NEW] "unsloth" uses 30% less VRAM, fits 2x larger batch sizes!
use_gradient_checkpointing = "unsloth", # True or "unsloth" for very long context
random_state = 3407,
use_rslora = False, # We support rank stabilized LoRA
loftq_config = None, # And LoftQ
)alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
{}
### Input:
{}
### Response:
{}"""
EOS_TOKEN = tokenizer.eos_token # Must add EOS_TOKEN
def formatting_prompts_func(examples):
instructions = examples["instruction"]
inputs = examples["input"]
outputs = examples["output"]
texts = []
for instruction, input, output in zip(instructions, inputs, outputs):
# Must add EOS_TOKEN, otherwise your generation will go on forever!
text = alpaca_prompt.format(instruction, input, output) + EOS_TOKEN
texts.append(text)
return { "text" : texts, }
dataset = load_dataset("json", data_files="/home/<user>/Ansh/Unsloth/Unsolth_commercient_finetune.json", split="train")
dataset = dataset.map(formatting_prompts_func, batched = True,)trainer = SFTTrainer(
model = model,
tokenizer = tokenizer,
train_dataset = dataset,
dataset_text_field = "text",
max_seq_length = 512, # Lower for VRAM savings
dataset_num_proc = 1, # Stick to fewer processes
packing = True,
args = TrainingArguments(
per_device_train_batch_size = 2, # Small batch size to avoid OOM
gradient_accumulation_steps = 10, # Accumulate gradients to compensate
warmup_steps = 10, # Gradual warmup
max_steps = 40, # Reasonable training time for your setup
learning_rate = 2e-4, # Learning rate for stability
fp16 = False, # Nvidia struggles with FP16, so leave it off
bf16 = is_bfloat16_supported(),
logging_steps = 1,
optim = "adamw_8bit", # Memory-efficient optimizer
weight_decay = 0.01,
lr_scheduler_type = "cosine", # Cosine decay is usually stable
seed = 3407,
output_dir = "outputs",
report_to = "none",
gradient_checkpointing = True, # Keep VRAM usage low
),
)
trainer_stats = trainer.train()
print(trainer_stats)
model.save_pretrained("Lamma_3_1_8B_Qlora_Co_Unsolth_Version_1_1") # Local saving
tokenizer.save_pretrained("Lamma_3_1_8B_Qlora_Co_Unsolth_Version_1_1")The Colab notebook's captured output is the clearest evidence in the folder of what packing does to a tiny dataset:
==((====))== Unsloth - 2x faster free finetuning | Num GPUs used = 1
\\ /| Num examples = 48 | Num Epochs = 30 | Total steps = 60
O^O/ \_/ \ Batch size per device = 2 | Gradient accumulation steps = 12
\ / Data Parallel GPUs = 1 | Total batch size (2 x 12 x 1) = 24
"-____-" Trainable parameters = 41,943,040/4,670,623,744 (0.90% trained)247 records packed into 48 sequences of 512 tokens means 60 steps at an effective batch of 24 is 30 epochs. Training loss reached 0.008 by step 59 in the local run, which is memorisation. Because 85 of the 247 records share the exact output "I am not trained to answer questions outside of my knowledge domain.", the cheapest solution the model found was to refuse. The notebook shows it: "How does Commercient's SYNC solution help businesses?" returned the refusal string, as did three other on-topic prompts. Only "Difference Between CRM and ERP?" got a real answer.
Every gate designed after this, from the keyword list to the Pinecone threshold, is a reaction to the finding that a small model cannot be trusted to know when to refuse.
Two variants reused the template: unsloth/Qwen2.5-7B (loss 1.08 → 0.448 in 40 steps, converging far slower than Llama on the same data) and a bilingual run that emitted an English and a Hindi text for every record with no language marker in the prompt, so inference had to append the literal string (In Hindi) to steer the output language.
The Unsloth inference scripts loaded the adapter directory with AutoModelForCausalLM.from_pretrained, never PeftModel; passed max_length=200, which counts the roughly 90-token prompt; and used a lowercase "content" in the input tag that differs from the training tag. Each is small, together they made the manual evaluation unreliable.
2.2 "Torchtune" that wasn't: nailing the plain Transformers recipe
Nothing in Torchtune/ imports the torchtune library. The folder reproduces the Unsloth run with stock transformers, peft and bitsandbytes, which was the point: drop the dependency and understand each piece. Four adapters were trained in 75 minutes on 27 March by editing rank and epochs between runs.
| Adapter | r / α | Epochs | Grad accum | Steps | Loss, first → last | Size |
|---|---|---|---|---|---|---|
fine_tuned_llama_torchtune | 8 / 16 | 5 | 8 | 150 | 6.44 → 0.086 | 13.6 MB |
…_V_1 | 8 / 16 | 5 | 8 | 150 | 6.45 → 0.085 (re-run) | 13.6 MB |
…_V_2 | 10 / 32 | 10 | 10 | 240 | 6.39 → 0.031 | 17.1 MB |
…_V_3 | 12 / 32 | 15 | 12 | 300 | 5.42 → 0.013 | 20.5 MB |
All four train only q_proj and v_proj. That was not a choice: LoraConfig was created without target_modules, and peft's default for Llama is the query and value projections. The adapter sizes confirm it. Always verify the saved adapter_config.json against what you intended.
def preprocess_data(examples):
prompt = examples["instruction"]
output = examples["output"]
input = examples['input']
# Format the input
input_text = f"### Instruction:\n{prompt}\n\n### input:\n{input}\n\n### output:\n{output}"
# ✅ FIXED: Proper Tokenization with Padding
tokenized = tokenizer(
input_text,
padding="max_length",
truncation=True,
max_length=768, # Increased max length for better context
return_tensors="pt" # Add return_tensors="pt" to return PyTorch tensors
)
# Set labels (model needs input_ids for training)
tokenized["labels"] = tokenized["input_ids"].squeeze()
tokenized["input_ids"] = tokenized["input_ids"].squeeze() # Squeeze input_ids as well
tokenized["attention_mask"] = tokenized["attention_mask"].squeeze() # Squeeze attention_mask as well
return tokenized
# ✅ LoRA Configuration (Low-Rank Adaptation for Efficiency)
lora_config = LoraConfig(
r=12, # Increased LoRA Rank for better adaptation
lora_alpha=32, # More impact on fine-tuning
lora_dropout=0.1, # Increased dropout to prevent overfitting
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)The initial loss above 6 is the signature of this recipe: roughly 100-token records padded to 768 with labels = input_ids, so the first steps are dominated by hundreds of pad positions. The fast drop below 0.1 within fifteen steps is the model learning to emit pad tokens, not domain knowledge.
The inference script is the first in the archive to load an adapter correctly, and the first to combine a domain-restricting preamble with a refusal instruction in the prompt.
model_name = "meta-llama/Meta-Llama-3.1-8B" # Or your base model name
adapter_path = "/home/<user>/Ansh/Torchtune/fine_tuned_llama_torchtune_V_2" # Path to your fine-tuned LoRA adapter
tokenizer = AutoTokenizer.from_pretrained(adapter_path) # Load from adapter path!
model = AutoModelForCausalLM.from_pretrained(
model_name,
device_map="auto", # Or specify your device
torch_dtype=torch.bfloat16,
)
model = PeftModel.from_pretrained(model, adapter_path) # Load LoRA adapter
def generate_prompt(instruction, input_text="Official Commercient Content"):
prompt = f"""Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request, using ONLY information related to Commercient. If you do not have any answer then say 'I am unable to provide response for this query'.
### Instruction:
{instruction}
### Input:
{input_text}
### Response:
"""
return promptTraining used ### input: and ### output: in lowercase with no preamble. Inference used ### Input: and ### Response: with a long preamble. To the tokenizer those are different strings, so the fine-tuned behaviour is never triggered. Keep one formatting function and import it in both places.
2.3 FineTunning Pipeline: the first end-to-end run
April is when the pieces became a pipeline. A real implementation guide (a 70 KB text export of a Word document) feeds four Groq generators, each with its own JSON schema. A preprocessor detects the schema per record and renders it to tagged text. A formatter reverses the tags into task-specific instructions. A trl SFTTrainer run trains for 968 steps. A TF-IDF classifier gates inference.
Generation with a JSON contract
The generators ask for a JSON list, get format instructions from LangChain's JsonOutputParser, strip Markdown fences from the reply, and validate each entry before appending. This plumbing carries forward almost unchanged into the production generators.
def generate_faqs_from_text_file(data):
try:
text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=200)
chunks = text_splitter.split_text(data)
groq_llm = ChatGroq(model_name="llama-3.3-70b-versatile", groq_api_key=GROQ_API_KEY) # Using Llama 3 70B versatile model
output_parser = JsonOutputParser()
# 4. Define Prompt for FAQ generation in Alpaca format
template = """You are an expert in generating Frequently Asked Questions (FAQs) based on given text content.
Your task is to generate around 50 FAQ pairs for each chunk of text provided.
Each FAQ pair should be in the Alpaca format (instruction, input, output).
The 'input' field should always be 'Official Commercient Content'.
The 'instruction' field should be the question phrased as an FAQ.
The 'output' field should be a concise and informative answer to the FAQ, derived from the provided text chunk.
Ensure the output is a structured JSON list of dictionaries, where each dictionary represents an FAQ pair in Alpaca format.
Format your response as JSON.
{format_instructions}
Text Chunk:
{text_chunk}
"""
prompt = ChatPromptTemplate.from_messages([
("system", "You are a helpful AI assistant that generates FAQs."),
HumanMessagePromptTemplate.from_template(template)
])
# Get format instructions from the output parser
partial_prompt = prompt.partial(format_instructions=output_parser.get_format_instructions())
# 5. Create LLMChain
faq_chain = LLMChain(llm=groq_llm, prompt=partial_prompt, output_parser=output_parser) # Clean response (sometimes contains markdown code blocks)
cleaned_response = response.strip().replace('```json', '').replace('```', '').strip()
try:
episodes = json.loads(cleaned_response)
if not isinstance(episodes, list):
print(f"Warning: Expected list but got {type(episodes)}")
continue
valid_episodes = 0
for episode in episodes:
if (isinstance(episode, dict) and
episode.get("input") == "Official Commercient Content" and
len(episode.get("podcast_style", [])) >= min_conversations_per_pair):
all_podcast_data.append(episode)
valid_episodes += 1
print(f"Added {valid_episodes} valid episodes from this chunk")
except json.JSONDecodeError as e:
print(f"JSON parsing failed: {e}")
print("Problematic response (first 200 chars):", cleaned_response[:200]) # Use cleaned_response here.Normalising four schemas
Because each generator emitted a different shape, the preprocessor detects the shape from the keys and renders everything to text with a bracket marker and a trailing tag line. This is a reasonable trick when you cannot control upstream formats, but note the cost: the trainer then has to parse that text back apart.
def detect_format(self, example):
"""Auto-detect format based on data structure"""
if 'podcast_style' in example:
return 'podcast'
elif 'story' in example and all(k in example['story'] for k in ['beginning', 'middle', 'end']):
return 'story'
elif 'instructions' in example and isinstance(example['instructions'], list):
return 'instruction'
elif ('question' in example and 'answer' in example) or ('instruction' in example and 'output' in example):
return 'faq'
else:
raise ValueError(f"Cannot detect format for example: {example.keys()}")
def format_to_text(self, example):
"""Convert any format to standardized text with markers"""
format_type = example["format"]
if format_type == "faq":
question = example.get('question', example.get('instruction', ''))
answer = example.get('answer', example.get('output', ''))
return f"[FAQ]\nQuestion: {question}\nAnswer: {answer}"
elif format_type == "podcast":
return f"[PODCAST]\n" + "\n".join(
f"{turn['speaker']}: {turn['line']}"
for turn in example["podcast_style"]
)
elif format_type == "instruction":
return f"[INSTRUCTION]\n" + "\n".join(example["instructions"])
elif format_type == "story":
return f"[STORY]\nBeginning: {example['story']['beginning']}\n" \
f"Middle: {example['story']['middle']}\n" \
f"End: {example['story']['end']}"def preprocess_data(examples):
# Extract the full text
text = examples["text"]
# Parse the content type, main content, and tag
content_parts = text.split("\nTag: ")
content_type, main_content = content_parts[0].split("]", 1)
content_type = content_type[1:] # Remove the leading "["
main_content = main_content.strip()
tag = content_parts[1].strip() if len(content_parts) > 1 else "Unknown"
# Format based on content type
if content_type == "FAQ":
# Split into question and answer
parts = main_content.split("\n")
question = parts[0].replace("Question: ", "")
answer = parts[1].replace("Answer: ", "")
formatted_text = f"### Instruction:\n{question}\n\n### input:\nTag: {tag}\n\n### output:\n{answer}"
elif content_type == "STORY":
# Split into beginning, middle, end
parts = main_content.split("\n")
beginning = parts[0].replace("Beginning: ", "")
middle = parts[1].replace("Middle: ", "")
end = parts[2].replace("End: ", "")
formatted_text = f"### Instruction:\nTell me a business transformation story\n\n### input:\nTag: {tag}\nBeginning: {beginning}\nMiddle: {middle}\n\n### output:\n{end}"
elif content_type == "PODCAST":
# Extract the dialogue
dialogue = "\n".join([line for line in main_content.split("\n") if line])
formatted_text = f"### Instruction:\nGenerate podcast dialogue about business systems\n\n### input:\nTag: {tag}\n\n### output:\n{dialogue}"
elif content_type == "INSTRUCTION":
# Use the steps as the output
steps = "\n".join([line for line in main_content.split("\n") if line.startswith("Step")])
formatted_text = f"### Instruction:\nProvide steps for ERP system implementation\n\n### input:\nTag: {tag}\n\n### output:\n{steps}"Every podcast shares one instruction and every instruction set shares another, so the model learns a fixed instruction-to-long-output mapping rather than a task. Stories put the beginning and middle in the input and train only on the ending. The production generators fixed this by having the teacher write a distinct instruction per record.
The Ray Tune sweep that never ran
Finetunning_Raytune.py is the archive's only hyperparameter search. It defines an ASHA scheduler, Optuna search, ten samples, and a search space over batch size, learning rate and epochs, minimising validation loss from a hand-written train/eval loop.
search_space = {
"batch_size": tune.choice([4, 8, 16]),
"learning_rate": tune.loguniform(1e-5, 1e-3),
"num_epochs": tune.choice([2, 3, 4])
}
scheduler = ASHAScheduler(
max_t=10,
grace_period=1,
reduction_factor=2
)
search_algorithm = OptunaSearch()
tuner = tune.Tuner(
trainable=train_model,
param_space=search_space,
tune_config=tune.TuneConfig(
metric="val_loss",
mode="min",
scheduler=scheduler,
search_alg=search_algorithm,
num_samples=10 # Number of configurations to try
),
run_config=RunConfig(
name="finetune_ray_tune",
storage_path=storage_path # Updated to use storage_path
)
)
results = tuner.fit()
best_result = results.get_best_result(metric="val_loss", mode="min")
print("Best hyperparameters: ", best_result.config)ray_results/ holds only a tuner.pkl and an empty storage marker: no trial ever reported. The reasons are all in the script. The model is loaded once in bf16 with no quantization and no LoRA as a module-level global that Ray must pickle into every worker. tune.with_resources is never called, so each trial gets one CPU and no GPU. A full-precision 8B model with AdamW states cannot fit a 24 GB card at batch 4 to 16 over 2048 tokens. And the DataLoader iterates an HF dataset without set_format("torch"), yielding lists rather than tensors.
Load the quantised base and LoRA inside the trainable, request a GPU per trial with tune.with_resources(train_model, {"gpu": 1}), sweep LoRA rank, alpha, learning rate and dropout rather than batch size, and let Ray report from a TrainerCallback instead of a hand-rolled loop.
The run that finished
Final_Finetunning.py (executed as temp.py according to W&B metadata) is the first trl SFTTrainer run in the archive. It trained 3,878 tagged records for 8 epochs and 968 steps in 2 hours 42 minutes, with loss falling from 6.54 to 0.0435 and token accuracy reaching 0.99. Two launches before it died with torch.OutOfMemoryError because another process was holding 19.4 GB on GPU 1 while device_map="auto" tried to shard onto it.
# Data collator
data_collator = DataCollatorForSeq2Seq(tokenizer, model=model, pad_to_multiple_of=8)
# LoRA configuration
lora_config = LoraConfig(
r=12,
lora_alpha=32,
lora_dropout=0.1,
bias="none",
task_type="CAUSAL_LM"
)
model = get_peft_model(model, lora_config)
# Training arguments
training_args = TrainingArguments(
output_dir="./fine_tuned_model",
per_device_train_batch_size=4,
gradient_accumulation_steps=8,
save_strategy="steps",
logging_steps=4,
learning_rate=2e-4,
weight_decay=0.01,
warmup_steps=10,
lr_scheduler_type="cosine",
max_grad_norm=0.3,
bf16=True,
num_train_epochs=8,
save_total_limit=3,
# load_best_model_at_end=True,
remove_unused_columns=False,
push_to_hub=False,
report_to="wandb",
logging_dir="./tensorboard_logs",
optim="adamw_8bit",
dataloader_drop_last=True,
)
# Initialize SFTTrainer
trainer = SFTTrainer(
model=model,
args=training_args,
train_dataset=train_dataset,
eval_dataset=val_dataset,
tokenizer=tokenizer,
data_collator=data_collator,
)
# Train the model
trainer.train()
# Save the trained model
trainer.save_model("./Commercient_fine_tuned_model")
tokenizer.save_pretrained("./Commercient_fine_tuned_model")The 0.99 token accuracy is inflated by the trivially predictable tokens (repeated pads, fixed instruction strings). The validation set is passed but never evaluated. Again the LoRA config has no target_modules, so this 968-step run trained only q_proj and v_proj.
The TF-IDF relevance classifier
To replace keyword lists, the pipeline generated 5,076 on-topic questions from the implementation guide and 5,000 general questions, labelled them 1 and 0, and trained a logistic regression over 5,000 TF-IDF features. The gate was then bolted in front of the QA path in the inference REPL.
data = pd.read_csv(csv_file)
if 'Questions' not in data.columns or 'is_relevant' not in data.columns:
raise ValueError("The CSV file must contain 'Questions' and 'is_relevant' columns.")
X = data['Questions'] # Features (questions)
y = data['is_relevant'] # Labels (0 or 1)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
vectorizer = TfidfVectorizer(stop_words="english", max_features=5000)
X_train_tfidf = vectorizer.fit_transform(X_train) # Fit and transform the training data
X_test_tfidf = vectorizer.transform(X_test) # Transform the test data
model = LogisticRegression(random_state=42, max_iter=1000)
model.fit(X_train_tfidf, y_train)
y_pred = model.predict(X_test_tfidf)
accuracy = accuracy_score(y_test, y_pred)
print("Classification Report:")
print(classification_report(y_test, y_pred))
print(f"Accuracy on the test dataset: {accuracy:.2f}")
joblib.dump(model, model_output_path)
joblib.dump(vectorizer, vectorizer_output_path) if is_podcast_query(user_query):
response = generate_podcast_response(user_query)
elif is_story_query(user_query):
response = generate_story_response(user_query)
else:
relevance = classify_question_relevance(user_query)
if relevance == 1:
response = generate_commercient_response(user_query)
else:
response = ("Thank you for your question. However, it seems to fall outside the scope "
"of Commercient's domain expertise. Please feel free to ask questions "
"related to Commercient, CRM, or ERP systems, and I'll be happy to assist you.")
print(f"Commercient Bot: {response}")The classification report was printed to stdout and never saved, so no accuracy figure survives. Five days later the final inference script commented the classifier call out and relied on rules inside the prompt plus a fallback check. A TF-IDF gate labels anything containing "sync", "ERP" or "CRM" as relevant regardless of meaning, and its negatives never included hard cases such as "Who is the CEO of Commercient?".
2.4 FINETUNING_ACCERLATE: the modular refactor and the device-map wall
At the end of May the scripts became a package: main.py --mode preprocess|train_classifier|fine_tune|run_pipeline|all, a config.py of dictionaries, rotating file logs, a CommercientFineTuner class, a BERT relevance classifier, EarlyStoppingCallback, and W&B. The base model switched to unsloth/gemma-2-9b-it and the data, now produced by the Era 3 generators, grew to 149,583 records. The design is sound and carried directly into the production pipeline. Nothing in this folder ever trained.
GEMMA_FINE_TUNING = {
"model_name": "unsloth/gemma-2-9b-it",
"output_dir": "models/fine_tuned_gemma_v_1",
"lora_config": {
"r": 16,
"lora_alpha": 32,
"lora_dropout": 0.1,
"bias": "none",
"target_modules": ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"],
"use_gradient_checkpointing": True
},
"quantization_config": {
"load_in_4bit": True,
"bnb_4bit_compute_dtype": "float16",
"bnb_4bit_quant_type": "nf4",
"bnb_4bit_use_double_quant": True
},
"device_map": "auto", #{"": 0}
"training_args": {
"per_device_train_batch_size": 1,
"per_device_eval_batch_size": 1,
"gradient_accumulation_steps": 10,
"learning_rate": 2e-5,
"num_train_epochs": 2,
"eval_strategy": "steps",
"eval_steps": 100,
"save_strategy": "steps",
"save_steps": 100,
"logging_steps": 10,
"lr_scheduler_type": "cosine",
"save_total_limit": 3,
"warmup_ratio": 0.1,
"max_grad_norm": 0.3,
"weight_decay": 0.2,
"optim": "adamw_torch",
"group_by_length": True,
"metric_for_best_model": "eval_loss",
"greater_is_better": False,
"early_stopping_patience": 5,
"early_stopping_threshold": 0.01
}
} def _init_model(self) -> None:
"""Initialize the model with quantization and LoRA."""
logger.info(f"Loading model: {self.model_name}")
quantization_config = None
if self.quantization_config:
logger.info("Applying quantization configuration")
quantization_config = BitsAndBytesConfig(**self.quantization_config)
self.base_model = AutoModelForCausalLM.from_pretrained(
self.model_name,
quantization_config=quantization_config,
device_map=self.device_map,
cache_dir=self.cache_dir,
trust_remote_code=True
)
if quantization_config:
logger.info("Preparing model for k-bit training")
self.base_model = prepare_model_for_kbit_training(
self.base_model,
use_gradient_checkpointing=self.lora_config.get("use_gradient_checkpointing", True)
)
logger.info("Applying LoRA configuration")
peft_config = LoraConfig(
task_type=TaskType.CAUSAL_LM,
**{k: v for k, v in self.lora_config.items() if k != "use_gradient_checkpointing"}
)
self.model = get_peft_model(self.base_model, peft_config)
self.model.print_trainable_parameters()The preprocessing stage ran in four seconds and is worth recording: 50,440 FAQs, 37,530 podcast turns, 30,342 stories and 31,271 instructional records, split 119,665 / 14,959 / 14,959. The classifier set it built is the problem: every record's input field became a positive example (149,583 of them) against at most 1,000 templated negatives, and for podcasts and stories the input is a topic descriptor rather than a question. A classifier that always answers "relevant" would have scored F1 0.997 on that data. The BERT training was launched three times and never finished.
The error that ate 2 June
Twenty-seven log files from a single day tell one story. The intended setup was two GPUs (CUDA_VISIBLE_DEVICES="4,5"), a 4-bit Gemma loaded with device_map, and Accelerate for multi-GPU. Every launch that got as far as trainer.train() died within forty seconds with the same message.
Training on 119665 samples with batch size 1 ... Total training steps: 23932 ... Estimated training time: 19.94 hours
ERROR - Error during training: You can't train a model that has been loaded in 8-bit precision on a
different device than the one you're training on. Make sure you loaded the model on the correct device
using for example `device_map={'':torch.cuda.current_device()}` or `device_map={'':torch.xpu.current_device()}`
Attempting to save checkpoint to models/fine_tuned_gemma_v_1/error_checkpointdevice_map={"": 0}.Two other details from the logs are worth keeping. At 07:27 and 08:19 the runs reported CUDA available: False because they were launched from a CPU-only conda environment. And an intermediate version of the script built a hand-written layer map using GPT-2 module names (transformer.h.0), which would never have matched Gemma's model.layers.N. When debugging device placement, print model.hf_device_map rather than guessing module names.
What Era 2 taught
Refusal examples must be a minority and must be paired with hard positives. A third of the data sharing one refusal string, trained for thirty epochs, produced a model that refused everything. The gate belongs outside the model until you have thousands of diverse examples.
Unsloth/Unsolth_commercient_finetune.json · Copy_of_Llama3_1_(8B)_Alpaca.ipynb cells 16–18Count epochs, not steps, when packing. max_steps=60 on 48 packed sequences is 30 epochs. Log trainer_state.json's epoch field and stop when validation loss turns, not when a step budget runs out.
Set target_modules explicitly and check the saved config. Every FineTunning Pipeline and Torchtune adapter trained only q and v because the argument was omitted. The intended all-projection LoRA existed only in Unsloth and ACCERLATE.
Share one formatter between training and inference. Lowercase ### input:/### output: at train time and ### Input:/### Response: at inference time silently disconnects the adapter from the prompt.
Stop padding to max_length. Records of about 100 tokens padded to 768 waste 85% of every step and, with labels = input_ids, train on pads. Use dynamic padding via the collator and, ideally, completion-only loss.
Fix the GPU before importing torch. CUDA_VISIBLE_DEVICES set after import does nothing; a quantised model must be loaded on the device the Trainer uses; Accelerator() inside python main.py is a no-op. Check nvidia-smi for other tenants before launching.
A relevance classifier needs real negatives. TF-IDF learned vocabulary, not intent; the BERT set was 99.3% positive and its positives were topic descriptors rather than questions. Collect hard negatives (near-domain questions the assistant must still refuse) before training any gate.
FineTunning Pipeline/Question_Classifier · FINETUNING_ACCERLATE/data_preprocessing.py:101Persist what you measure. The Ray sweep has no trials, the classifier report went to stdout, 34 of 36 TensorBoard files hold only arguments, and the Colab loss table was an unsaved widget. Only two runs in this era left a loss curve.
FineTunning Pipeline/ray_results · tensorboard_logs · Question_Classifier/Logistic_Regression.pyName folders after what they do. "Torchtune" never used torchtune, "Finetunning_Axolotl.py" never used Axolotl, and the run that produced the best April adapter was temp.py. Future-you pays for every misleading name.
Era 3 · 1 May – 28 Jul 2025 · Finetuning_Pipeline_30000/, COMMERCIENT_CLAUD_FINETUNING/, COMMERCIENT_CLAUDE_FINETUNING/, COMMERCIENT_GEMMA_FINETUNING/Productionising: bulk data, a reference trainer, one long run, and a multi-customer CLI
The final three months turned the experiments into a pipeline. A self-hosted QwQ-32B generated roughly 31,000 records in three days, then 149,583 across the four data types. A Gemma-2-9B trainer with chat templates, early stopping and memory callbacks became the reference implementation and produced the archive's only completed run with a validation curve: 117 hours, 12,400 steps, best eval loss 0.913. The trainer was then wrapped in a per-customer command-line pipeline, a DuckDB store and a Flask front end, and a FastAPI server put a retrieval-based relevance gate in front of the model.
- 1–3 MayBulk generation with QwQ-32B. Four generator scripts run in a
screensession for three days; per-chunk counts are cut from 225 to 25 after the model leaks its reasoning and returns error objects. - 14 MayGemma v_2.
unsloth/gemma-2-9b-itQLoRA on ~27k Alpaca-text records, stopped at step 1,300 of 6,765 with eval loss 0.815 still falling. This checkpoint is what the API serves in July. - 2–3 JunAxolotl configs. 149,583 records converted to ShareGPT conversations; YAML and DeepSpeed ZeRO-2 configs generated; never trained.
- 4–6 JunLaunch storm. 168 launches in four days across two trainer variants; one completes. 124 launches on 4 June alone, none of which trained.
- 6–11 JunThe production run. Gemma-2-9B-it, r 32, 119,666 chat records, 12,400 steps in 117 hours, early-stopped on a plateau at eval loss 0.913.
- 10 Jun – 15 JulRow-wise generation pipeline. CSV input, token counting, UUID run ids, per-customer prompt customisation, atomic writes, bash wrapper.
- 12 Jun – 28 JulMulti-customer trainer. Shell → setup.py → data_processing → fine_tuning with pynvml GPU selection and a network-share layout. Only 3-step smoke tests run through it.
- 23 JulServing. FastAPI
/generatewith an OpenAI-embedding + Pinecone relevance gate; Streamlit chat UI.
3.1 Bulk generation: 31,000 records from a reasoning model
The May generators replaced Groq with a self-hosted Qwen/QwQ-32B behind the internal LlamaWrapper, and replaced the four different output schemas of April with one: every record is {instruction, input, output} where the instruction begins with a fixed system sentence. Each script chunks the source text with RecursiveCharacterTextSplitter, asks for N records per chunk as a JSON list, and validates every entry.
text_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
chunks = text_splitter.split_text(data)
...
template = """You are an expert in generating Frequently Asked Questions (FAQs) based on given text content.
Your task is to generate a JSON list containing around {faqs_per_chunk} FAQ pairs based on the provided text chunk.
Each FAQ pair should be formatted as a JSON object with three keys: 'instruction', 'input', and 'output'.
Here are the specific requirements for each key:
1. 'instruction': This field should contain a clear instruction for a Commercient assistant. It must start with "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP." followed by the specific task, like "Answer the following question:".
2. 'input': This field should contain the actual question derived from the text chunk, phrased as a user query.
3. 'output': This field should contain a concise and informative answer to the question in the 'input' field, derived *only* from the provided text chunk.
Ensure the overall output is a structured JSON list of these {faqs_per_chunk} FAQ dictionaries.
Format your response as JSON.
{format_instructions}
Text Chunk:
{text_chunk}
"""
# Use Langchain's prompt template. We still use JsonOutputParser to get the format instructions.
output_parser = JsonOutputParser()
prompt_template = ChatPromptTemplate.from_messages([
("system", "You are a helpful AI assistant that generates data in a specific JSON format."),
HumanMessagePromptTemplate.from_template(template)
])The parser is three layers deep because QwQ is a reasoning model and does not always return clean JSON: slice the outermost […], else strip Markdown fences and parse the whole text, else regex-salvage individual objects and de-duplicate them. The story, instructional and podcast scripts add a fourth check for the {"error": "Insufficient information"} dictionary QwQ returns when a chunk is too small for the requested count.
cleaned_text = raw_response_text.strip()
parsed_data = None
# Try robustly extracting the main JSON block (expecting a list [ ... ])
list_start = cleaned_text.find('[')
list_end = cleaned_text.rfind(']')
if list_start != -1 and list_end != -1 and list_end > list_start:
potential_json_text = cleaned_text[list_start : list_end + 1]
try:
parsed_data = json.loads(potential_json_text)
except json.JSONDecodeError:
parsed_data = None # Reset if parsing fails
# If main block parsing failed, try parsing the whole cleaned text (after fence removal)
if parsed_data is None:
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[len('```json'):].strip()
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-len('```')].strip()
try:
parsed_data = json.loads(cleaned_text)
except json.JSONDecodeError:
parsed_data = None
# --- Salvage Attempt: Only if primary parsing failed or yielded very few results ---
salvage_threshold = FAQS_PER_CHUNK * 0.95
if len(valid_entries) < salvage_threshold or parsed_data is None:
potential_objects = re.findall(r'\{\s*"instruction"\s*:\s*".*?".*?"input"\s*:\s*".*?".*?"output"\s*:\s*".*?".*?\}', raw_response_text, re.DOTALL)
for obj_str in potential_objects:
try:
item = json.loads(obj_str)
item_tuple = tuple(sorted(item.items()))
if is_valid_faq_entry(item) and item_tuple not in [tuple(sorted(e.items())) for e in valid_entries]:
valid_entries.append(item)
except json.JSONDecodeError:
logging.warning("Failed to parse a potential salvaged object string as JSON.")The screen log of the three-day session records what it took to make a reasoning model produce bulk JSON reliably:
| Attempt | Requested per chunk | Outcome |
|---|---|---|
| FAQ, 1 May 09:50 | 20 | 1,821 records in 1 h 44 min; 4 chunks "not valid JSON after cleaning" |
| Podcast, 1 May 10:12 | 80 | 1,422 records; model returned {"error": "Insufficient information …"} for many chunks |
| FAQ, 1 May 22:35 | 225 | 1,088 records, mean 23.7 per chunk; 18 parse failures; QwQ's chain-of-thought leaked into the output ("Okay, let me tackle this query…") |
| FAQ, 2 May 04:08–04:20 | 125–225 | 0 records: 46 × HTTP 400 from vLLM, prompt plus max_tokens of 40–60k exceeded the server context |
| Groq structured output, 2 May 05:52 | 225 | 291 records total; tool-call JSON too large, "Failed to call a function"; abandoned |
| Podcast, 2 May 07:21 | 30 | 6,524 records in 7 h 33 min at 86 s per chunk |
| FAQ, 2 May 23:03 | 25 | 2,820 records in 2 h 37 min, mean 24.96 per chunk, one salvage. The best-behaved run |
Ask for at most 25 to 30 records per call. Above roughly 100 the model emits its reasoning as prose, wraps output in fences, truncates, or returns an error object. Chunk the source text small (300 to 800 characters) so each call has just enough context. Keep the salvage path and validate every record's fields. Throughput on the shared vLLM box was 10 to 15 records per minute, dominated by the model's thinking time per call rather than by record count.
One bug shipped: every script saves with open(filename, 'a') and json.dump(list), so three of the six output files are two concatenated JSON lists that json.load rejects with "Extra data". Any downstream merge either read only the first list or regenerated the data. Podcast outputs also contain literal \\n sequences because the model copied the escaped example in the prompt.
3.2 The adapter that ended up in production
fine_tuned_gemma_v_2/checkpoint-1300 is the adapter the FastAPI server loads. It came from a May run on unsloth/gemma-2-9b-it with r 16, Alpaca text, batch 1 × 12 accumulation and learning rate 1e-4. Its trainer_state.json shows eval loss falling monotonically from 1.381 at step 100 to 0.815 at step 1,300, then nothing: the run was stopped at 0.58 of an epoch, 19% of its 6,765-step schedule, with the curve still improving. It was the first run in the archive with a validation set, and it is charted in the results chapter.
3.3 Axolotl configs that never ran
On 3 June a setup script merged the four data dumps (149,583 records), converted them to ShareGPT conversations, split train/val/test, and generated Axolotl YAML for Llama-3.1-8B-Instruct and Gemma-2-9B plus a DeepSpeed ZeRO-2 JSON. The configs are worth keeping for one reason: they set train_on_inputs: false, the completion-only loss that no trl-based trainer in the archive ever enabled. They also set lora_alpha: 16 with lora_r: 32, the inverse of the α = 2r convention used everywhere else, and misuse dataset_prepared_path as an eval set. The results/ directory is empty.
adapter: lora
base_model: meta-llama/Meta-Llama-3.1-8B-Instruct
chat_template: chatml
datasets:
- conversation: chatml
path: data/processed/train.json
type: sharegpt
deepspeed: configs/zero2.json
lora_r: 32
lora_alpha: 16
lora_dropout: 0.05
lora_target_linear: true
learning_rate: 0.0002
lr_scheduler: cosine
micro_batch_size: 2
gradient_accumulation_steps: 4
num_epochs: 3
optimizer: adamw_bnb_8bit
sample_packing: true
sequence_len: 2048
train_on_inputs: false # completion-only loss — the setting every hand-rolled trainer lacked
early_stopping_patience: 3
eval_steps: 100
special_tokens:
pad_token: <|end_of_text|> # a real pad token distinct from EOS3.4 The reference trainer
COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.py is the archive's most complete training script and the one to reuse. Beyond the recipe shown in the technique chapter, it adds four things the earlier eras lacked.
Chat templates, and Gemma's missing system role
Records became {"messages": [...]} lists and trl 0.12's SFTTrainer applied the tokenizer's chat template itself. The first data processor emitted three roles; Gemma's instruction-tuned template rejects a system role, so data_processing_2.py folds the instruction into the first user turn.
instruction = str(item.get("instruction", "")).strip()
input_text = str(item.get("input", "")).strip()
output_text = str(item.get("output", "")).strip()
# Construct the first user message content
user_content = ""
if instruction and input_text:
user_content = instruction + "\n\n" + input_text # Combine system and user with separator
elif instruction:
user_content = instruction # Use only instruction if no input
elif input_text:
user_content = input_text # Use only input if no instruction
# Build the messages list
messages = []
if user_content:
messages.append({"role": "user", "content": user_content})
# Only add assistant turn if there was a preceding user turn AND assistant content exists
if output_text:
messages.append({"role": "assistant", "content": output_text})
elif output_text:
logger.warning(f"Skipping item {i}: Only 'output' field contains content without preceding instruction/input. Content: {item}")
skipped_count += 1
continueTarget-module validation
After an error checkpoint of 7.8 GB appeared (a config had put embed_tokens and lm_head in the target list, and Gemma's vocabulary is 256k tokens), the trainer gained a method that checks every configured target against the model's actual leaf modules and warns specifically about the two vocabulary projections.
def get_target_modules(self, model):
"""
Dynamically get target modules based on model architecture
"""
configured_modules = self.config.get("lora_target_modules", [])
all_modules = set()
for name, module in model.named_modules():
if len(list(module.children())) == 0: # leaf modules
all_modules.add(name.split('.')[-1])
logger.info(f"Available leaf modules in model: {sorted(list(all_modules))}")
valid_modules = []
for module_name in configured_modules:
if any(module_name == name.split('.')[-1] for name, _ in model.named_modules() if len(list(module.children())) == 0):
valid_modules.append(module_name)
else:
if module_name in ["embed_tokens", "lm_head"]:
logger.warning(f"Configured target module '{module_name}' not typically recommended for LoRA due to high memory consumption, skipping. Consider removing it from your config.")
else:
logger.warning(f"Configured target module '{module_name}' not found in model or is not a leaf module.")
if not valid_modules:
logger.warning("No configured target modules found or valid. Using default attention modules.")
common_targets = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
...
if not valid_modules:
raise ValueError("No suitable target modules found for LoRA adaptation!")
return valid_modulesA subtle bug: inside the comprehension, module refers to the loop variable leaked from the earlier loop, so the leaf check is constant. The method still works because target names are compared by suffix.
Memory callbacks and error checkpoints
Two TrainerCallbacks empty the CUDA cache every 50 steps and after each evaluation, and log allocated and reserved memory every 20 steps. If trainer.train() raises, the adapter is saved to an error_checkpoint_<timestamp> directory before the exception propagates, and the process exits with a code that W&B records.
class EmptyCacheCallback(TrainerCallback):
def on_step_end(self, args, state, control, **kwargs):
if state.global_step > 0 and state.global_step % 50 == 0:
if torch.cuda.is_available():
torch.cuda.empty_cache()
def on_evaluate(self, args, state, control, **kwargs):
if torch.cuda.is_available():
torch.cuda.empty_cache()
class MemoryMonitoringCallback(TrainerCallback):
def on_step_end(self, args, state, control, **kwargs):
if state.global_step > 0 and state.global_step % 20 == 0: # Log every 20 steps
if torch.cuda.is_available():
allocated = torch.cuda.memory_allocated() / (1024**3)
cached = torch.cuda.memory_reserved() / (1024**3)
logger.info(f"Step {state.global_step} - GPU Memory: Allocated: {allocated:.2f} GB, Cached: {cached:.2f} GB")
except Exception as e:
logger.error(f"An error occurred during trainer.train(): {e}", exc_info=True)
try:
error_checkpoint_path = output_dir / f"error_checkpoint_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
trainer.save_model(str(error_checkpoint_path))
logger.warning(f"Saved a checkpoint to {error_checkpoint_path} after encountering an error during training.")
except Exception as save_e:
logger.error(f"Failed to save checkpoint after error during training: {save_e}", exc_info=True)
raise eThe launch storm of 4–6 June
The trainer created its output directory before loading the model, so models/fine_tuned/ holds 90 timestamped folders of which 80 are empty. W&B recorded 168 launches in four days across this script and a multi-process sibling, fine_tunning_accelerate.py. The failure classes, from the logs:
- CUDA out of memory at model load, 61 launches on 4 June: concurrent launches already held the GPUs. Several were started in the same second by
acceleratewith six ranks. - "You can't train a model loaded in 8-bit precision on a different device", 18 launches: the same failure documented in Era 2.
- Argument errors:
TrainingArgumentshas nonum_cyclesparameter (the YAML still carries it); anSFTTrainer.__init__signature mismatch; a wandbfinish(reason=)that does not exist. - Tensor-parallel incompatibility:
'DTensor' object has no attribute 'compress_statistics'when peft met a bitsandbytes model sharded bytp_plan="auto". - Out of memory during training at sequence length 4096 with batch 4, twice, identically configured.
- A learning rate of 0.02, almost certainly a typo for 2e-5: loss reached 878,115 with NaN gradients within 50 steps.
- A corrupt checkpoint write (
unexpected pos 144411776) that killed the best-behaved short run at step 500.
3.5 The 117-hour run
The launch that started at 07:23 on 6 June ran until 11 June. Its configuration is the one in the recipe chapter: google/gemma-2-9b-it, r 32 / α 64, learning rate 1e-5 with cosine-with-restarts and 200 warmup steps, batch 2 × 10, sequence length 1024, fp16, 119,666 training and 29,917 validation records, two visible GPUs with the model sharded by device_map="auto".
| Measure | Value | Note |
|---|---|---|
| Scheduled steps | 17,949 | 3 epochs at effective batch 20 |
| Steps completed | 12,400 | 2.07 epochs; stopped by EarlyStoppingCallback |
| Wall time | 117.2 h | 421,974 s; 0.043 steps/s, about 23 s per optimizer step |
| Training loss | 3.80 → 0.73 | noisy, logged every 5 steps; mean over the run 0.975 |
| Eval loss | 2.21 → 0.913 | best at step 11,900; the last five evaluations within 0.003 of it |
| Eval cost | ~48 min | per evaluation of 29,917 samples at eval batch 2, every 100 steps |
| GPU memory | 5.6 GB | allocated, steady; 7.8–14 GB reserved |
| Output | 432 MB | final_adapter/ plus checkpoints 11,900–12,400 and the tokenizer |
The eval curve never turns upward, so the stop was a plateau rather than overfitting; the ceiling is the data. Two facts about throughput matter for planning the next run. Sharding a 4-bit 9B model across two 24 GB cards is not data parallelism: one card idles at any moment and the effective batch stays at 20, hence 0.85 samples per second. And the evaluation schedule consumed roughly half the wall-clock time. With a 500-sample validation set and evaluation every 500 steps, the same run would have finished in about 60 hours on one GPU.
3.6 The multi-customer command-line pipeline
From mid-June the trainer and the generators were wrapped for repeated use on customer data: a bash wrapper activates the conda environment and passes arguments, an orchestrator runs data processing and training as subprocesses, and every path is derived from a customer name under a network share.
GPU selection with pynvml
The answer to the "different device" error that had cost days in June was to choose the GPU at runtime: query free memory with NVML, expose only cards with at least 18 GB free, and let device_map place the model there. Note that max_gpus=2 still allows two cards to be selected, in which case device_map="auto" shards and the error can recur; the smoke-test logs show it eleven more times.
def get_available_gpus(min_memory_gb=12, max_gpus=2):
"""
Returns a list of GPU indices that have at least `min_memory_gb` free memory,
up to a maximum of `max_gpus`.
"""
pynvml.nvmlInit()
gpu_count = pynvml.nvmlDeviceGetCount()
available_gpus = []
for i in range(gpu_count):
handle = pynvml.nvmlDeviceGetHandleByIndex(i)
memory_info = pynvml.nvmlDeviceGetMemoryInfo(handle)
free_memory_gb = memory_info.free / (1024 ** 3)
print(f"GPU {i}: Free memory = {free_memory_gb:.2f} GB")
if free_memory_gb >= min_memory_gb:
available_gpus.append(i)
if len(available_gpus) == max_gpus:
break # Exit the loop early
pynvml.nvmlShutdown()
return available_gpus
class CommercientFineTuner:
def __init__(self, config_path, output_path=None, source_path=None):
# Dynamic GPU selection
available_gpus = get_available_gpus(min_memory_gb=18)
if available_gpus:
os.environ["CUDA_VISIBLE_DEVICES"] = ",".join(map(str, available_gpus))
logger.info(f"Using GPUs: {os.environ['CUDA_VISIBLE_DEVICES']}")
else:
log_to_customer_json("ERROR:- Fine tuning process Failed( No Sufficent Memory found in GPUs )")
logger.error("No GPU with sufficient memory found! Stopping execution.")
os._exit(1) # Forceful exitSetting CUDA_VISIBLE_DEVICES here works even though torch is already imported, because the CUDA context is not created until the first CUDA call. The same trick would have fixed FINETUNING_ACCERLATE/main.py, which set the variable after a CUDA call had already happened.
Row-wise generation: tokens, run ids, atomic writes
The July generation pipeline treats each CSV row as one source document. It counts tokens with tiktoken, splits rows above 31,000 tokens with a character-based splitter and re-verifies each chunk, tags every run with a UUID that is appended to run_ids.json, and writes output through a temporary file and os.replace so a crash cannot leave a half-written JSON.
def chunk_text_by_tokens(self, text: str, max_tokens: int = None) -> List[str]:
"""Split text into chunks based on token count."""
if max_tokens is None:
max_tokens = self.token_limit
token_count = self.count_tokens(text)
if token_count <= max_tokens:
return [text]
char_limit = max_tokens * 4
overlap_chars = 200
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=char_limit,
chunk_overlap=overlap_chars,
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = text_splitter.split_text(text)
verified_chunks = []
for chunk in chunks:
chunk_tokens = self.count_tokens(chunk)
if chunk_tokens <= max_tokens:
verified_chunks.append(chunk)
else:
logging.warning(f"Chunk still too large ({chunk_tokens} tokens), splitting further")
smaller_char_limit = char_limit // 2
smaller_splitter = RecursiveCharacterTextSplitter(
chunk_size=smaller_char_limit,
chunk_overlap=overlap_chars // 2,
length_function=len
)
verified_chunks.extend(smaller_splitter.split_text(chunk))
return verified_chunks base_template = """You are an expert in generating Frequently Asked Questions (FAQs) based on given text content.
Your task is to generate a JSON list containing approximately {faqs_per_row} FAQ pairs based on the provided text content.
Focus on generating questions and answers that are directly answerable *only* from the text provided.
Each FAQ pair should be formatted as a JSON object with three keys: 'instruction', 'input', and 'output'.
Here are the specific requirements for each key:
1. 'instruction': This field must contain a clear instruction for a Commercient assistant. It must start with "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP." followed by the specific task, like "Answer the following question:". Ensure this prefix is exactly as specified.
2. 'input': This field should contain the actual question derived from the text content, phrased as a user query.
3. 'output': This field should contain a concise and informative answer to the question in the 'input' field, derived *only* from the provided text content. Do not include information not present in the text.
{custom_prompt_addition}
Ensure the overall output is a structured JSON list of approximately {faqs_per_row} FAQ dictionaries.
Format your response as JSON.
{format_instructions}
Source Text Content:
{source_text}
"""
# Combine base template with customer customization
full_template = base_template.replace(
"{custom_prompt_addition}",
prompt_customization if prompt_customization else ""
)The last edits on 15 July were left half-finished. The savers index a list by run id (existing_entries[pipeline_run_id] on a list raises TypeError, visible in the last log); three modules reference an all_faqs variable that only exists in the FAQ module; the FAQ module writes to SYNTHETIC_DATA/<customer>.json while the others write to SYNTHETIC_DATA/<customer>/<customer>.json; the bash wrapper's default max_tokens=20000s is not an integer; and stage 2 still expects the June file naming. Each is a ten-minute fix, but they must all be done before the pipeline works again.
The pieces around it
- DuckDB store (
DUCK_DB/dbhelper.py): a table keyed by customer id with the four raw JSON dumps asJSONcolumns and anINSERT OR REPLACEupsert. Retrieval and query methods are commented out; the 755 MB database holds five copies of the same test data. - Flask upload UI (
USER_INTERFACE/app.py): document upload, per-format prompt fields, toggles for fine-tuning and Pinecone sync. The three back-end functions are placeholders that write fake files, sleep, and upsert dummy vectors. - Status channel: both stage-2 scripts append status strings under the finetuning id in
/mnt/cnn-shr/transcriber/out/<customer>.json, and exit withos._exit(1)on error, which also skips W&B's finish call.
3.7 Serving: a retrieval-based relevance gate
The July API answers the question that every era struggled with, "should the model answer this at all?", without a trained classifier. It embeds the incoming question with OpenAI's text-embedding-3-small, queries every namespace of a Pinecone index of company documents, and refuses if the best match scores below 0.4. Only then does the merged Gemma model generate. The retrieved passages are not injected into the prompt: retrieval is used purely as a gate.
@app.post("/generate", response_model=GenerationResponse)
async def generate_response_api(request: GenerationRequest):
user_query = request.prompt
# 1. Retrieve relevant context from Pinecone
try:
retrieved_results = query_all_namespaces(
index=pinecone_index,
query_text=user_query,
total_top_k=5,
fetch_k_per_namespace=10
)
# Simple relevance check
if not retrieved_results or retrieved_results[0]['score'] < 0.4:
print("Context relevance below threshold. Replying with a standard message.")
return GenerationResponse(response_text="I can only answer questions related to Commercient. How can I help you with that?")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error retrieving context from Pinecone: {e}")
try:
messages = [
{"role": "user", "content": f"\n\nQuestion: {request.prompt}"}
]
prompt_formatted = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
inputs = tokenizer(prompt_formatted, return_tensors="pt").to(generation_model.device)
with torch.no_grad():
outputs = generation_model.generate(
**inputs,
max_new_tokens=request.max_new_tokens,
temperature=request.temperature,
top_p=request.top_p,
do_sample=True,
eos_token_id=tokenizer.eos_token_id,
pad_token_id=tokenizer.eos_token_id
)
response_text = tokenizer.decode(outputs[0][len(inputs["input_ids"][0]):], skip_special_tokens=True)
return GenerationResponse(response_text=response_text.strip())
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))Two mismatches to fix before reuse: the served adapter was trained on Alpaca text (### Instruction / ### Input / ### Response) but is prompted through the Gemma chat template with a bare "Question:" prefix, and the Streamlit client posts to port 8002 while the server listens on 8001. The decode correctly slices off the prompt tokens, which several earlier inference scripts forgot.
What Era 3 taught
Bulk JSON from an LLM is a batch job with a contract. Fixed schema, small per-call counts, a layered parser, per-entry validation, run ids, atomic writes and resumability. The May scripts had the first four; the July pipeline added the rest and then broke its own savers.
Finetuning_Pipeline_30000/DATASET GENERATION · COMMERCIENT_GEMMA_FINETUNING/COMMERCIENT_DATASET_GENERATIONUse the chat template and respect the model's roles. Gemma-IT has no system role; fold the instruction into the user turn. Let the trainer render the template so inference can use the identical function.
COMMERCIENT_CLAUDE_FINETUNING/src/data_processing_2.pySize the validation set for the signal you need. 29,917 samples every 100 steps cost half of a 117-hour run. A few hundred samples give the same early-stopping decision.
gemma-2-9b-it-20250606_072330 trainer_state.jsonTwo GPUs with device_map="auto" is model sharding, not speed. Effective batch and throughput are those of one GPU. Use one card per process, or launch real data parallelism with accelerate launch and one model copy per rank.
Never target vocabulary projections on a 256k-vocab model. The 7.8 GB error checkpoint and the cross-device matmul crash both trace to embed_tokens and lm_head in the LoRA target list.
Serialise one launch at a time. 124 launches on one day, 61 of them dying at model load because earlier launches still held the GPU. Check nvidia-smi, or let the script pick a free GPU, before every start.
Write the hyperparameters file from the run, not by hand. The 143k-sample adapter's hyperparameters.json claims r 16; its adapter_config.json says r 8. Dump trainer.args and the peft config at save time.
Retrieval can be the gate. Once documents are embedded, "is this on-topic?" becomes a similarity threshold with no classifier to train, no negatives to collect, and a path to full RAG later.
Finetuning_Pipeline_30000/api_commercient_ansh_model.pyKeep the two stages of a pipeline on one file contract. Generation and training drifted apart in July (per-customer file vs per-type files), so the CLI only ever ran on 6-record smoke tests. Define the interchange format once and test it with a fixture.
COMMERCIENT_GEMMA_FINETUNING/GEMMA_FINETUNING/src/data_processing.pyEvidenceResults and metrics
Every number here was extracted programmatically from the archive: 54 trainer_state.json files, 311 Weights & Biases run directories, 106 TensorBoard event files and the text logs. After de-duplication that is 332 distinct training launches. 66 completed, 8 were stopped by hand, 258 crashed, and 266 lived for under a minute. Only 21 runs logged five or more loss values, and exactly one multi-epoch run has a validation curve.
The one completed run with a validation curve
Gemma-2-9B-it, 6 to 11 June 2025. Training loss is logged every 5 steps and is noisy at this effective batch of 20; the eval loss is the signal. It falls steeply for the first 2,000 steps, flattens, reaches its minimum of 0.913 at step 11,900, and the next five evaluations sit within 0.003 of it. Early stopping ended the run at 12,400 of 17,949 scheduled steps. The gap between training and eval loss is moderate and the eval curve never turns upward: this is a plateau set by the data, not overfitting.
checkpoint-12400/trainer_state.json.Nine runs on one scale
Putting the eras side by side on a shared logarithmic loss axis makes the archive's central pattern visible. The Llama runs of March and April drive training loss down to between 0.01 and 0.09 with no validation set, over 5 to 80 epochs of a few hundred to a few thousand records: memorisation, unmeasured. The Gemma runs of May and June sit an order of magnitude higher, stop earlier, and are the only ones that can be trusted because they were evaluated.
Dataset size over time
The training set grew by five orders of magnitude in four months, then collapsed to six records for the command-line smoke tests. The jumps line up with the changes in generation method: a real source document in March, four generators with a JSON contract in April, a self-hosted 32B model running for days in May, and the merged 149,583-record dump in June.
Launches and outcomes
The W&B directories tell the operational story: iteration was fast and most launches never reached the first optimizer step. The June trainer alone was launched 168 times in four days. The command-line pipeline's 39 "completed" runs are all three-step smoke tests on the six-record TEST customer, ending at the same training loss of 3.7013 every time.
Bar length is proportional to launch count; the full width is 168. Coloured segments are completed runs, translucent segments are hand-stopped partial runs, grey is crashed. Source: wandb-metadata.json, wandb-summary.json and output.log in each run directory.
What crashed, grouped by cause across all folders:
- GPU already occupied (CUDA out of memory at model load): the largest class. Concurrent launches, or another tenant on the shared box, held the memory.
- Quantised model on the wrong device: the bitsandbytes "different device" error, 18 times in June and 11 more times in the CLI smoke tests.
- Argument and API mismatches:
num_cycles,SFTTrainersignature,wandb.finish(reason=), missingmodel_namekey, dataset path relative to the wrong working directory. - Configuration errors: a learning rate of 0.02 that diverged to a loss of 878,115; sequence length 4096 at batch 4 that ran out of memory twice.
- Infrastructure: one corrupt checkpoint write, one run from a CPU-only environment, three runs from the wrong directory.
Runs worth remembering
| Run | Date | Era | Base model | r / α | lr | Steps | Last train loss | Best eval | Outcome |
|---|---|---|---|---|---|---|---|---|---|
| fine_tunned_llama3_1_qlora_ansh_v_5_TAG | 26 Feb | 1 | Llama-3.1-8B | 8 / 32 | 2e-4 | 69 | 1.30 | — | completed, no eval |
| fine_tuned_llama3_qlora_ansh_v_7 | 27 Feb | 1 | Llama-3.1-8B | 10 / 16 | 2e-4 | 60 | 0.23 | — | completed, 1.16 GB adapter |
| Unsloth/outputs (bilingual) | 13 Mar | 2 | Llama-3.1-8B-Instruct (Unsloth 4-bit) | 16 / 16 | 2e-4 | 80 | 0.018 | — | 80 epochs, memorised |
| Torchtune/fine_tuned_llama_torchtune_V_3 | 27 Mar | 2 | Llama-3.1-8B | 12 / 32 | 2e-4 | 300 | 0.013 | — | 15 epochs, memorised |
| FineTunning Pipeline/Fine_tunned_ALL_SYNC_Data | 17 Apr | 2 | Llama-3.1-8B | 12 / 32 | 2e-4 | 845 | 0.039 | — | completed in 38 min |
| FineTunning Pipeline/fine_tuned_model → Commercient_fine_tuned_model | 22 Apr | 2 | Llama-3.1-8B | 12 / 32 | 2e-4 | 968 | 0.044 | — | completed in 2 h 42 min after two OOMs |
| Finetuning_Pipeline_30000/fine_tuned_gemma_v_2 | 14 May | 2 | unsloth/gemma-2-9b-it | 16 / 32 | 1e-4 | 1,300 / 6,765 | 0.61 | 0.815 | stopped at 19% · the served adapter |
| All_Commercient_Data_Finetune_Gemma_version_1 | 4 Jun | 3 | unsloth/gemma-2-9b-it | 8 / 16 | 1e-5 | ? | — | — | adapter saved, no loss log survives |
| W&B hbdtwvv9 | 5 Jun | 3 | gemma-2-9b-it | 16 / 32 | 0.02 | 50 | 878,115 | — | diverged · learning-rate typo |
| gemma-2-9b-it-20250605_062904 | 5 Jun | 3 | gemma-2-9b-it | 16 / 32 | 2e-5 | 500 / 17,949 | 1.22 | 1.14 | crashed · corrupt checkpoint write |
| gemma-2-9b-it-20250605_093320, 20250606_040600 | 5–6 Jun | 3 | gemma-2-9b-it | 32 / 64 | 1e-5 | 125 / 18,695 | 1.88 | 2.10 | OOM · seq 4096, batch 4 |
| gemma-2-9b-it-20250606_050521 | 6 Jun | 3 | gemma-2-9b-it | 32 / 64 | 1e-5 | 275 / 17,949 | 1.30 | 1.53 | killed · relaunched two hours later |
| gemma-2-9b-it-20250606_072330 | 6–11 Jun | 3 | gemma-2-9b-it | 32 / 64 | 1e-5 | 12,400 / 17,949 | 0.73 | 0.913 | completed · early stopped after 117 h |
| TEST customer smoke tests (38 runs) | 12 Jun – 28 Jul | 3 | gemma-2-9b-it | 32 / 64 | 1e-5 | 3 | 3.70 | — | 6 records, pipeline plumbing tests |
Hardware, then and now
February to April ran on a workstation with two RTX 4090s (24 GB each), CUDA 12.5, first in a Python 3.9 environment and then 3.12 and 3.13. From June the work moved to a six-GPU box with training pinned to two visible cards, Python 3.10, torch 2.5.1 with CUDA 12.4, transformers 4.46.3, trl 0.12.2, peft 0.15.2 and bitsandbytes 0.46.0. At the time of writing the same environment carries torch 2.8 and three RTX 4090s are visible, so any script that hardcodes CUDA_VISIBLE_DEVICES="4,5" refers to a machine that no longer exists.
Cross-cuttingRelevance gating: eight answers to one question
"Should the model answer this at all?" was asked in the very first inference script and answered differently eight times. The lineage matters because it is the clearest example in the archive of an idea being refined by failure: every gate was a response to the previous one letting something through, or to the model itself refusing the wrong things.
| # | Approach | Where it acts | What it actually checks | How it failed |
|---|---|---|---|---|
| 1 | Cosine to seed sentence | before generation | Semantic similarity to a single 20-word sentence | One sentence cannot represent a domain; thresholds from mean-pooled raw embeddings do not transfer |
| 2 | Tag-conditioned system prompt | inside the prompt | Asks a 69-step base model to "remain silent" | Silence is not a learnable behaviour from tagged text; the emptiness check was unreachable in code |
| 3 | Refusal + keyword rejection sampling | after generation | Whether the answer contains a refusal phrase or a domain word | Filters the answer, not the question; generic keywords make it permissive; three attempts triple latency |
| 4 | Refusal examples in training data | inside the weights | Nothing explicit; hopes the model learns the boundary | 34% refusals over 30 epochs produced refusal collapse on on-topic questions |
| 5 | Keyword list over the question | before generation | Substring match on 12 words | Misses paraphrases, admits any sentence containing "sales" |
| 6 | TF-IDF + logistic regression | before generation | Word distribution of the question | Learned vocabulary, not intent; balanced but with easy negatives; accuracy never recorded; commented out after five days |
| 7 | Rules inside the prompt | inside the prompt | Model self-judges against listed examples | Depends on the model's compliance; the fallback check compared the answer to the prompt string |
| 8 | BERT classifier | before generation | Would have learned "is this a question?" from 99.3% positive data | Never trained to completion; the negative set shrank to ten hand-written sentences; bypassed with if True: |
| 9 | Retrieval threshold | before generation | Whether any indexed company document is similar enough to the question | 0.4 on OpenAI cosine scores is permissive; the retrieved text is then thrown away |
How to build the gate next time
- Gate on the question, before generation. Approaches 3, 4 and 7 let the model spend tokens first and judged afterwards. Approach 9 has the right shape.
- Collect hard negatives. The dangerous inputs are near-domain: "Who is the CEO of Commercient?", "Tell me a joke about CRM", "What is today's date?". The March
Questions.txtalready listed them; no classifier was ever trained on them. - Calibrate the threshold on a labelled set. Neither 0.5 in February nor 0.4 in July was chosen from data. A few hundred labelled questions and a precision/recall curve settle it in an afternoon.
- Use retrieval twice. Once the index exists, pass the top passages into the prompt as context. The gate becomes RAG, and the adapter's job shrinks to style and format, which is what small-data fine-tuning is good at.
- Keep refusals out of the fine-tuning set until the positive set is large and diverse, and even then cap them at a few percent with distinct phrasings.
Cross-cuttingData formats and prompt templates
Seven record shapes and five prompt templates appear in the archive. Knowing which one a given adapter was trained on is the difference between a model that answers and one that emits its training template back at you.
Record shapes, in the order they appeared
| Shape | First seen | Example | Used by |
|---|---|---|---|
| Raw sentences | 24 Feb | Did You Know that integrating CRM and ERP systems can increase business productivity by up to 30%? | adapters v, v_4, v_5 |
{instruction, output, tag} | 27 Feb | {"instruction": "Did you know?", "output": "[COM_SPECIAL] integrating CRM…", "tag": "official commercient content"} | adapter v_6 (2 records) |
{question, answer} with sentinel | 3 Mar | {"question": "What is Commercient SYNC?", "answer": "Commercient SYNC is … ###END###"} | Ditillation FAQ adapters |
Alpaca {instruction, input, output} | 6 Mar | {"instruction": "What is Commercient SYNC?", "input": "Official Commercient Content", "output": "Commercient SYNC is a powerful…"} | Unsloth, Torchtune, Gemma v_2, all May–June generators (with the system sentence moved into instruction) |
| Four schemas → tagged text | 10 Apr | [PODCAST]\nHost: We're exploring…\nGuest: One of the best features…\nTag: Official Commercient Content | FineTunning Pipeline runs |
Chat {messages: [...]} | 4 Jun | {"messages": [{"role": "user", "content": "You are a helpful assistant for Commercient… Generate a short podcast-style conversation about [Topic].\n\nTopic: Database Permissions"}, {"role": "assistant", "content": "Host: What database permissions…"}]} | the reference trainer and the CLI pipeline |
ShareGPT {conversations: [{from, value}]} | 3 Jun | {"conversations": [{"from": "system", "value": "…"}, {"from": "human", "value": "…"}, {"from": "gpt", "value": "…"}]} | Axolotl configs (never trained) |
The four data types
From April onward every dataset mixed four kinds of record, each produced by its own generator prompt. The June dump had 50,440 FAQs, 31,271 instructional records, 37,530 podcast snippets and 30,342 stories. One real record of each, from All_Commercient_*.json:
{"instruction": "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP. Answer the following question:",
"input": "What server access is required to configure the ODBC DSN for Commercient?",
"output": "You must have access to the server where your ERP is installed."}
{"instruction": "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP. Provide step-by-step instructions for accessing the CRM App in Commercient Admin.",
"input": "Process: Accessing the CRM App in Commercient Admin",
"output": "Step 1: Navigate to the Commercient Admin screen.\nStep 2: Go to the CRM setup section.\nStep 3: ..."}
{"instruction": "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP. Generate a short podcast-style conversation about [Topic].",
"input": "Topic: CRM Setup Location in Admin Screen",
"output": "Host: Where can I access the CRM setup in Commercient? Guest: In the Commercient Admin screen, navigate to the CRM setup section. There, you'll find a direct link to the CRM App."}
{"instruction": "You are a helpful assistant for Commercient, providing information ONLY about Commercient, CRM, and ERP. Tell a very detailed and engaging story about how a retail company overcame a data synchronization bottleneck using the Commercient CRM Sync App.",
"input": "Company/User Type: Retail Startup, Challenge: Inability to sync more than 10 CRM records with their ERP, limiting customer outreach, Solution: Upgraded Commercient license enabling full Sync App access.",
"output": "At BrightBoutique, a rapidly growing online retailer, the team faced a critical roadblock. ..."}Note what the input field means per type: a question for FAQs, a process name for instructions, a topic for podcasts, a scenario summary for stories. This is why the classifier that used input as its positive examples learned mostly non-questions.
Templates at training time versus inference time
The single most common way an adapter was wasted in this archive is a template mismatch: training on one string layout and prompting with another. The table lists every pairing found.
| Adapter | Trained on | Prompted with | Match? |
|---|---|---|---|
| v, v_4, v_5 | raw sentences, no template | raw question | consistent (but nothing to learn) |
| Ditillation llama3_8b_faq | Question: …\nAnswer: … ###END### | system sentence + Question: …\nAnswer: | close |
| Unsloth Llama / Qwen | Alpaca with preamble, ### Instruction / ### Input / ### Response + EOS | same Alpaca, input tag lowercase "content" | near miss |
| Torchtune V_2 | ### Instruction / ### input / ### output, no preamble, no EOS | Alpaca preamble + ### Input / ### Response | mismatch |
| Commercient_fine_tuned_model (Apr) | ### Instruction / ### input:\nTag: … / ### output | same layout plus a rules block and conversation history | partial |
| Gemma v_2 (served) | Alpaca ### Instruction / ### Input / ### Response | Gemma chat template with "\n\nQuestion: …" | mismatch |
| FINETUNING_ACCERLATE inference_without_classifier | Alpaca | Gemma <start_of_turn> tags, or ChatML tags labelled "Llama-style" | mismatch |
| gemma-2-9b-it-20250606_072330 | Gemma chat template via SFTTrainer, instruction in the user turn | Gradio app: chat template, bare question | partial (instruction sentence missing) |
The fix is structural, not careful typing: define one function that turns a record into text, call it from the training script, and import the same function in the inference script. The reference trainer got halfway there by letting trl render the chat template; the serving code then wrote its own prompt by hand.
# Alpaca: a fixed English scaffold, model-agnostic, needs an explicit EOS
alpaca_prompt = """Below is an instruction that describes a task, paired with an input that provides further context. Write a response that appropriately completes the request.
### Instruction:
{}
### Input:
{}
### Response:
{}"""
text = alpaca_prompt.format(instruction, input, output) + tokenizer.eos_token
# Chat template: the model's own control tokens, rendered by the tokenizer
messages = [
{"role": "user", "content": instruction + "\n\n" + input}, # Gemma-IT has no system role
{"role": "assistant", "content": output},
]
text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
# → "<bos><start_of_turn>user\n…<end_of_turn>\n<start_of_turn>model\n…<end_of_turn>\n"The generator prompts that held up
The prompt contract that survived from April to July has five parts: a role sentence, the exact record count, a per-key specification with a mandatory prefix for instruction, the parser's own format instructions, and the source text last. The July version adds a {custom_prompt_addition} slot per customer and the phrase "derived only from the provided text" in every field description, which is the cheapest hallucination control available. The full templates for all four types are quoted in chapter 3.1 and 3.6.
Cross-cuttingLessons, as a checklist for the next fine-tuning job
Everything below was learned the expensive way somewhere in the archive. It is written to be reused at any company on any domain, in the order a project actually runs.
Before generating data
- Start from real documents. A seed sentence produced only hallucinated facts (Era 1). The first usable data came from a real product page; the production data came from an implementation guide and a knowledge-base export.
- Fix the record schema first and make every generator emit it. Four schemas in April cost a preprocessor and a reverse-parser; one schema from May onward cost nothing.
- Write a distinct instruction per record. Shared instructions ("Generate podcast dialogue about business systems") teach a fixed mapping, not a task.
- Decide the data mix and the refusal share up front. 34% refusals with one phrasing caused refusal collapse; the production data had no refusals and used a retrieval gate instead.
While generating data
- Ask the teacher for a JSON list of at most 25–30 records per call, with the parser's format instructions in the prompt and the source text last.
- Chunk small (300–800 characters, or by token count for long rows) so each call has just enough context, and say "derived only from the text" in every field description.
- Parse in layers: outer
[…]slice, fence strip, regex salvage, per-entry validation, de-duplication. Detect the model's own error objects. - Never append JSON to a file. Load, extend, write to a temporary file,
os.replace. Three of six May datasets were unreadable because ofopen(…, 'a'). - Track runs. A UUID per run, a log per customer, and a fixed file-naming contract that the training stage reads. The July stages drifted apart on exactly this.
Tokenisation and labels
- Give the model a real pad token.
pad_token = eos_tokenplus the LM collator masks the end-of-sequence signal; the model never learns to stop. - Mask the prompt. Use completion-only loss (trl's completion collator or Axolotl's
train_on_inputs: false). No trl-based trainer in the archive did. - Pad dynamically.
padding="max_length"to 512–768 on 100-token records wasted most of every step and trained on pads. - Use the chat template of an instruction-tuned base, respect its roles (Gemma has no system role), and share one formatting function between training and inference.
- Watch packing arithmetic. 247 records packed into 48 sequences turned 60 steps into 30 epochs.
LoRA configuration
- Pass
target_modulesexplicitly and read the savedadapter_config.jsonafterwards. peft's default silently trained only q and v in six adapters. - Never target
lm_headorembed_tokensunless you mean to: 1.16 GB on Llama, 7.8 GB and a device error on Gemma. - Use α = 2r as the default, dropout 0.05–0.1, and prefer the explicit
BitsAndBytesConfig(NF4, double quant, one compute dtype) over theload_in_4bit=Trueshorthand. - Call
prepare_model_for_kbit_trainingbeforeget_peft_modeland verify trainable parameters are non-zero before training starts.
Training arguments
- Start at lr 2e-4 for LoRA and tune from there; log the value in the run name so a 2e-2 typo is visible before it costs a run.
- Compute the effective batch and epochs from the data size and write them into the config comments correctly. Stale comments claimed 64 when the value was 20.
- Match precision end to end: compute dtype, model dtype and trainer flag agree (bf16 on Ampere and later).
- Keep
gradient_checkpointingandgroup_by_lengthon for memory; sequence length 1024 fit two 9B runs where 2048 and 4096 did not. - Add early stopping with
load_best_model_at_endand a metric; the callback asserts if you forget the latter.
Hardware and launching
- Choose the GPU before the first CUDA call (env var before
import torch, or pynvml selection beforetorch.cudais touched), then load withdevice_map={"": 0}. - One process, one GPU for QLoRA;
device_map="auto"across cards is sharding, not speed. For real data parallelism useaccelerate launchwith one model copy per rank. - Check
nvidia-smibefore every launch and never start a second run while the first holds the memory. 61 launches on one day died this way. - Create the output directory after the model loads, or clean up on failure; 80 empty timestamp folders are noise you will have to explain later.
Evaluation and tracking
- Always hold out a validation set, but size it for the decision: a few hundred samples evaluated every few hundred steps. 29,917 samples every 100 steps cost half of a 117-hour run.
- Persist every metric you compute. The classifier report, the Ray sweep and the Colab loss table all vanished because they lived only in stdout or a widget.
- Log the exact script and config with the run (W&B
programonly saved the file name; three of the trainers behind saved adapters are gone) and writehyperparameters.jsonfrom the trainer, not by hand. - Use one W&B project name. Six were used for one pipeline.
- Evaluate behaviour, not just loss: a fixed set of on-topic questions and hard negatives run after every training, with outputs saved.
Inference and serving
- Load adapters with
PeftModel.from_pretrained(base, adapter), slice the prompt off the decoded output, and passdo_sample=Truewhen you set temperature. - Gate on the question before generating, with a threshold calibrated on labelled hard negatives; retrieval against a document index is the cheapest gate that generalises.
- Then use the retrieved passages as context. Fine-tuning teaches style and format; retrieval supplies facts.
- Merge into a bf16 base for serving when quality matters; merging into a 4-bit base is lossy. Check that the client and server agree on a port.
Hygiene
- No secrets in code or notebooks. Provider tokens and an internal API key were pasted straight into scripts and notebooks across this work before they were moved behind
.env. Read them from the environment from the first commit; a key in a notebook cell outlives the experiment. - No absolute paths to home directories; every script broke the first time the project folder moved.
- Name things by what they do. "Torchtune" without torchtune, "Axolotl" without Axolotl, and
temp.pyas the real trainer. - Fill in the model card. Every one of the 22 adapter READMEs is the untouched peft template.
ReferenceFolder index and glossary
Where to look in the archive when a chapter above is not enough. Paths are relative to the archive root. Vendored code and caches are listed once at the end.
| Folder | Files worth opening | Skip |
|---|---|---|
| / (top level) | synthetic_data_generation.py (12 generator prompts) · fine_tunning_method_2json.py (the template trainer) · Inference_FT_synthetic_Lamma_3.py (embedding gate) · SyntheticData_to_Embeddings.ipynb (the regex parser) · CLAUDE.md (map for coding assistants) | the three near-identical Inference_FT_* copies, training_data.json |
| fine_tuned_llama3_qlora*, fine_tunned_llama3_1_qlora_ansh_v_* | adapter_config.json in each; checkpoint-*/trainer_state.json for loss history; fine_tuned_llama3_qlora_ansh_v_7/training_args.bin | weights, optimizer states, template READMEs |
| Ditillation/ | Synthatic_data_generation.ipynb cell 3 (the FAQ prompt) · demo_FT.py (first all-projection LoRA) · inference.py (rejection sampling) · faq.json · Source.txt | thinc-9.1.1/, *.whl, wandb/ |
| GroQ/ | Synthetic_data_generation_json.py · faq_data_groq.json (247 records, the first Alpaca-shaped set) | the Hindi variant |
| Axolotl/, Langgraph/ | Axolotl/config.yml (invalid schema, historical only) | everything else |
| Unsloth/ | Fintunning_lamma.py · Copy_of_Llama3_1_(8B)_Alpaca.ipynb (cell outputs show refusal collapse) · Unsolth_commercient_finetune.json · Questions.txt (the manual eval set) · outputs/checkpoint-60/trainer_state.json | unsloth_compiled_cache/, the inference scripts |
| Torchtune/ | lamma_ft.py · Inference_Lamma_FT.py (first correct PeftModel load) · the four fine_tuned_llama_torchtune*/adapter_config.json | temp.py, unsloth_compiled_cache/ |
| FineTunning Pipeline/ | Dataset Generation/Synthetic_data_generation.py · Dataset PreProcesing/Preprocess.py · Finetunning_Axolotl.py (tag-reversing formatter) · Finetunning_Raytune.py · Final_Finetunning.py · Question_Classifier/Logistic_Regression.py · Final_Fintunning_Inference.py · screenlog.0 (the OOM story) · tensorboard_logs/ (two files with scalars) | logs/ (args-only event files), ray_results/, tokenized_cache/, outputs/ |
| FINETUNING_ACCERLATE/ | config.py · fine_tuning.py · bert_classifier.py · data_preprocessing.py · logs/commercient_20250602-*.log (the device-map failure, 27 files) · attached_assets/*.json (the four data dumps, 149,583 records) | training_log.txt (empty), models/classifier/ (empty), setup_env.py |
| Finetuning_Pipeline_30000/ | DATASET GENERATION/FAQs_Generation.py (layered parser) · DATASET GENERATION/screenlog.0 (three days of generation) · fine_tuned_gemma_v_2/checkpoint-1300/trainer_state.json · api_commercient_ansh_model.py · streamlitapp.py · All_Commercient_Data_Finetune_Gemma_version_1/hyperparameters.json | the 50 per-run generation logs, temp.py |
| COMMERCIENT_CLAUD_FINETUNING/ | finetuning_pipeline.py · configs/llama3.1_8b.yml, gemma2_9b.yml, zero2.json | axolotl/ (vendored clone), results/, models/ (empty) |
| COMMERCIENT_CLAUDE_FINETUNING/ | src/fine_tuning.py (the reference trainer) · src/data_processing_2.py · configs/training_config.yaml · logs/training.log (the June runs) · models/fine_tuned/gemma-2-9b-it-20250606_072330/ (the production adapter and its trainer_state) · fine_tunning_accelerate.py | the 80 empty timestamp folders, wandb/ |
| COMMERCIENT_GEMMA_FINETUNING/ | GEMMA_FINETUNING/run_finetuning.sh, setup.py, src/*.py, requirements.txt · COMMERCIENT_DATASET_GENERATION/pipeline_rowwise.py, pipeline_rowwise.sh, config.json, the four generator modules · DUCK_DB/dbhelper.py · USER_INTERFACE/app.py | pipeline.py (dead), Story_generation.py (superseded), Log/ (empty files), the 755 MB DuckDB file |
| axolotl/, */axolotl/, Ditillation/thinc-9.1.1/, */unsloth_compiled_cache/, */__pycache__/ | — | vendored third-party code and caches; not part of the research |
Glossary
- QLoRA
- Fine-tuning with LoRA adapters on top of a base model whose weights are stored in 4-bit. The base is frozen and dequantised on the fly; only the adapters train.
- NF4, double quantisation
- The 4-bit "NormalFloat" data type used by bitsandbytes, and the further compression of its per-block scaling constants. Together they cut a 9B model to roughly 6 GB.
- compute dtype
- The precision of the actual matrix multiplications after dequantisation: fp16 or bf16. It must agree with the trainer's mixed-precision flag.
- LoRA rank r, alpha α
- The inner dimension of the adapter matrices and the scale applied to their product (α/r). Larger r means more trainable parameters; the archive used r 8 to 32 with α = 2r from March onward.
- target modules
- The linear layers that receive adapters: attention projections (q, k, v, o) and MLP projections (gate, up, down). Vocabulary projections (embed_tokens, lm_head) are huge and normally excluded.
- prepare_model_for_kbit_training
- peft helper that casts norms to fp32, enables gradient checkpointing and input gradients so training through a quantised base works.
- effective batch size
- per-device batch × gradient accumulation steps × number of data-parallel processes. With
device_map="auto"the process count is one. - packing
- Concatenating several short records into one sequence of
max_seq_length. Efficient, but it changes what "one step" and "one epoch" mean. - completion-only loss
- Masking prompt tokens with label -100 so the loss is computed only on the response. Not used by any trl-based trainer in the archive.
- chat template
- The tokenizer's Jinja template that turns a list of role/content messages into the model's control-token format. Gemma-IT's has no system role.
- early stopping
- Stop when the monitored metric has not improved by more than a threshold for N evaluations. Requires
load_best_model_at_endand a metric name. - device_map
- How accelerate places model layers on devices.
"auto"shards across all visible GPUs;{"": 0}puts everything on one. - relevance gate
- Any check that decides whether the model may answer a question at all. In the archive: embedding similarity, keywords, classifiers, prompt rules, and finally a retrieval threshold.
- teacher model
- The large model that writes the synthetic training records: Llama-3.3-70B, then Groq-hosted Llama 70B, then a self-hosted QwQ-32B.