Ansh Mangukiya · portfolio
Engineering write-up17 Feb – 28 Jul 202522 folders · one researcher · 3 × RTX 4090

Production QLoRA Pipeline

Five months of teaching a small open model to answer questions about one company: how synthetic training data was generated with a large teacher model, how QLoRA adapters were trained across five toolchains, why most launches failed, and the recipe that finally held. Reconstructed from the archive's code, logs, checkpoints and 311 Weights & Biases runs, so the folder never has to be reopened.

5 months
February to July 2025, three distinct eras
332
training launches found in checkpoints, W&B and TensorBoard
66 / 258
launches completed / crashed. Most died in under a minute
36
saved model directories, 22 of them with a usable adapter
2 → 143,599
training records, smallest to largest dataset
117 h
longest run: Gemma-2-9B, 12,400 steps, early-stopped

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.

Company docs guide · articles · CSV rows Teacher LLM Llama-3.3-70B → Groq 70B → QwQ-32B via vLLM chunk → prompt → JSON list Records {instruction, input, output} FAQ · story · instructional · podcast validated per entry 2 → 143,599 records QLoRA training base in 4-bit NF4, frozen LoRA r 8→32 on projections Trainer → SFTTrainer → early stop Llama-3.1-8B → Gemma-2-9B-it adapter: 13 MB → 432 MB Relevance gate cosine → keywords → classifier → RAG Answer or refuse FastAPI + Streamlit The same documents later fill a Pinecone index that the gate queries, so retrieval and training share one source.
The loop that every folder implements. The arrow across the bottom is the late-stage insight: once the documents are embedded for retrieval, the gate no longer needs a trained classifier.

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.

FolderEraWhat it holdsChapter
*.py, *.ipynb at top level1Smoke tests, twelve-genre generator, first trainer, embedding-gated inference1.1–1.5
fine_tuned_llama3_qlora*, fine_tunned_llama3_1_qlora_ansh_v_*1Seven early adapters and their checkpoints1.6
Ditillation/1Teacher-written FAQ data, two trainers, refusal + keyword inference1.7
GroQ/, Axolotl/, Langgraph/1Groq generator, an unused Axolotl config, an unrelated LangGraph demo1.8
Unsloth/2Unsloth Colab template, Qwen and Hindi variants, six checkpoints2.1
Torchtune/2Plain Transformers trainer (no torchtune), four adapters, first keyword gate2.2
FineTunning Pipeline/2Generation, preprocessing, Ray Tune, SFTTrainer run, TF-IDF classifier2.3
FINETUNING_ACCERLATE/2Modular pipeline, BERT classifier, the device-map failure2.4
Finetuning_Pipeline_30000/3Bulk QwQ generation, the served Gemma adapter, FastAPI + Streamlit3.1, 3.6
COMMERCIENT_CLAUD_FINETUNING/3Axolotl YAML and DeepSpeed configs (never trained), vendored axolotl clone3.3
COMMERCIENT_CLAUDE_FINETUNING/3The reference trainer, 90 launch directories, the 117-hour run3.4
COMMERCIENT_GEMMA_FINETUNING/3Multi-customer CLI, row-wise generation, DuckDB store, Flask UI3.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 chapterResults

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.

  1. 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.
  2. 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.
  3. Attach LoRA adapters with LoraConfig(r, lora_alpha, target_modules, lora_dropout) and get_peft_model. Only the adapter matrices train; the base stays frozen.
  4. Format records into one text field, either an Alpaca template or the tokenizer's chat template, and train with Trainer or trl's SFTTrainer.
  5. Save the adapter (a few megabytes to a few hundred), then reload it with PeftModel.from_pretrained(base, adapter), optionally merge_and_unload() for serving.
x hidden state, d = 4096 W (frozen) 4-bit NF4 · d × d q,k,v,o · gate,up,down dequantised to fp16/bf16 per matmul A r × d · trainable B d × r · trainable × α / r + h = W x + (α/r) B A x What r and α do r = rank: 8 → 32 in the archive params ≈ 2 · r · d per target layer α/r scales the update; α = 2r was the convention from March onward What gets saved only A and B → adapter_model.safetensors r 8 on q,v: 13.6 MB · r 32 on 7 proj: 432 MB unless lm_head / embed_tokens are targets: then the full 128k–256k vocab head ships (1–8 GB) Serving: merge_and_unload() folds (α/r)·B·A into W so inference runs one matmul per layer. Merging into a 4-bit base is lossy; merge into a bf16 base when quality matters.
How a LoRA adapter modifies one linear layer. Every "target module" named in 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.

COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.pylines 154–190 · quantisation, load, k-bit preparation
            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
COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.pylines 232–239, 556–566, 592–604 · LoRA, early stopping, SFTTrainer
        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 directoryDateBase modelrαTarget modulesSize
fine_tuned_llama3_qlora, …_ansh_v, …_v_4_instruct, …_v_5_TAG, …_v_6_TAG_JSON25–27 FebLlama 3 / 3.1 8B832q_proj, v_proj13.6 MB
fine_tuned_llama3_qlora_ansh_v_727 FebLlama-3.1-8B10167 projections + lm_head1.16 GB
Ditillation/Commercient_faq_fine_tune_v_128 FebLlama-3.1-8B832q_proj, v_proj13.6 MB
Ditillation/llama3_8b_faq_finetuned3 MarMeta-Llama-3-8B16327 projections168 MB
Unsloth/Lamma_3_1_8B_Qlora_Co_Unsolth_Version_1_111 MarLlama-3.1-8B (Unsloth 4-bit)16167 projections168 MB
Unsloth/Qwen_2_5_7B_Qlora_Co_Unsolth_Version_1_111 MarQwen2.5-7B (Unsloth 4-bit)16167 projections161 MB
Torchtune/fine_tuned_llama_torchtune, _V_127 MarLlama-3.1-8B816q_proj, v_proj (peft default)13.6 MB
Torchtune/…_V_2 / _V_327 MarLlama-3.1-8B10 / 1232q_proj, v_proj (peft default)17.1 / 20.5 MB
FineTunning Pipeline/llama3-8b-finetuned14 AprMeta-Llama-3-8B64128q, k, v, o218 MB
FineTunning Pipeline/Fine_tunned_ALL_SYNC_Data, fine_tuned_model, Commercient_fine_tuned_model17–22 AprLlama-3.1-8B1232q_proj, v_proj (peft default)20.5 MB
Finetuning_Pipeline_30000/fine_tuned_gemma_v_2 (served)14 Mayunsloth/gemma-2-9b-it16327 projections216 MB
Finetuning_Pipeline_30000/All_Commercient_Data_Finetune_Gemma_version_14 Jununsloth/gemma-2-9b-it8167 projections (its hyperparameters.json claims r 16)108 MB
COMMERCIENT_CLAUDE_FINETUNING/…/20250606_040433/error_checkpoint6 Jungoogle/gemma-2-9b-it32647 projections + embed_tokens, lm_head7.8 GB
COMMERCIENT_CLAUDE_FINETUNING/…/20250606_072330/final_adapter11 Jungoogle/gemma-2-9b-it32647 projections432 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.
Finetuning_Pipeline_30000/api_commercient_ansh_model.pylines 126–156 · load, attach, merge for serving
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, tokenizer

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

  1. 17–20 FebSmoke tests. Load meta-llama/Llama-3.1-8B-Instruct in fp16 and call generate on a trivial prompt.
  2. 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.
  3. 25 FebFirst QLoRA runs. Four W&B runs log loss 0.0, grad_norm nan. A variant with paged_adamw_32bit and batch 2 produces the first two adapters.
  4. 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).
  5. 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 plus lm_head.
  6. 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.
  7. 5 MarAxolotl drafted. A YAML config that was never valid Axolotl schema and never ran.
  8. 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 Alpaca instruction/input/output shape.

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.

1 seed sentence "Commercient integrates…" 12 prompt chains PromptTemplate | LlamaWrapper Llama-3.3-70B via vLLM T 0.5–0.9 · regex "cleaning" Output.txt nltk → 256 sentences Train on raw text adapters v · v_4_instruct · v_5_TAG · 33–69 steps Filter + regex parse cosine ≥ 0.5 to seed → str(list) → regex 2 records survive → adapter v_6 · 3 steps The corpus has no facts to draw on beyond the seed, so the teacher invents them ("increase productivity by up to 30%").
The v0 loop. Everything downstream of the seed sentence is teacher hallucination, and the one attempt to structure it collapsed to two training examples. The right half of this picture is why Era 1 pivoted to real source documents within a week.

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.

synthetic_data_generation.pylines 1–22 · the first chain
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})
synthetic_data_generation.pylines 183–208 · instructional guide with the first bracket tag
        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_instruction
Why the regex cleaning hurt

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

fine_tunning_method_2json.pylines 17–38 · tokenization and labels
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)
fine_tunning_method_2json.pylines 40–66 · 4-bit quantization and the LoRA adapter
# 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()
fine_tunning_method_2json.pylines 68–101 · collator, arguments, training, save
# 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.

input_ids Question: What… Answer: SYNC is… …your ERP system. <eos> <pad> <pad> pad_token = eos_token → same id labels = input_ids trained trained trained trained trained trained loss on prompt and on hundreds of pads + LM collator trained trained trained -100 -100 -100 EOS masked too: model never learns to stop what you want -100 trained trained trained -100 -100 distinct pad id, completion-only loss Symptoms seen in the archive: run-on generations, the "###END###" sentinel added to answers, and initial losses above 6 from training on pad tokens.
Where the loss lands. The archive never used completion-only masking or a distinct pad token. The third row is what actually trained in Era 1 and most of Era 2. The fourth row is the target: add a real pad token (or use the tokenizer's reserved one), mask the prompt, and keep EOS trainable.
The NaN runs of 25 Feb

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.

Inference_FT_synthetic_Lamma_3.pylines 8–34
# 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.

Inference_FT_synthetic_Lamma_3.pylines 36–71 · embedding gate
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.

SyntheticData_to_Embeddings.ipynbcells 10–13 · the serialization bug and the regex fitted to it
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.

AdapterBase modelLoRAStepsTraining dataSizeWhat changed
fine_tuned_llama3_qloraLlama-3.1-8Br 8 · α 32 · q,v33~90 raw sentences13.6 MBFirst adapter that trained without NaN
…_ansh_vMeta-Llama-3-8Br 8 · α 32 · q,v33same13.6 MBLlama 3 base for comparison
…_v_4_instructLlama-3.1-8B-Instructr 8 · α 32 · q,v33same13.6 MBInstruct base, but no chat template used
…_v_5_TAGLlama-3.1-8Br 8 · α 32 · q,v69~180 tagged sentences13.6 MBBracket tags in the text, paired with the tag-conditioned prompt
…_v_6_TAG_JSONLlama-3.1-8Br 8 · α 32 · q,v32 JSON records13.6 MBFirst structured records, from the regex parser
…_ansh_v_7Llama-3.1-8Br 10 · α 16 · all 7 projections + lm_head60140 records, 10 epochs1.16 GBFirst wide LoRA; targeting lm_head saved the whole 1.05 GB head
Why v_7 is 85 times larger

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.

Ditillation/Synthatic_data_generation.ipynbcell 3 · the FAQ prompt
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.

Ditillation/demo_FT.pylines 62–89 · k-bit preparation and all-projection LoRA
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.

Ditillation/inference.pylines 97–138 · keyword check and rejection sampling
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 fail

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

GroQ/Synthetic_data_generation_json.pylines 26–32, 59–87 · Groq client and Question:/Answer: parser
    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

01

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.py
02

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

synthetic_data_generation.py:542 · Ditillation/Source.txt
03

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.

fine_tunning_method_2json.py:20,69 · Ditillation/Finetune.py:36 · Ditillation/demo_FT.py:46
04

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.

fine_tunning_method_2json.py · Ditillation/demo_FT.py
05

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.

fine_tuned_llama3_qlora_ansh_v_7/adapter_config.json · Ditillation/demo_FT.py:55
06

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.

Inference_FT_synthetic_Lamma_3_1_instruct.py
07

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.

all Era 1 TrainingArguments
08

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.

synthetic_data_generation.py · GroQ/.env

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.

  1. 5–6 Mar49 FAQ pairs become Alpaca records. Torchtune/Dataset.json is converted to {instruction, input, output} with a constant input tag; the set grows to 247 records, 85 of them refusals.
  2. 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.
  3. 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 PeftModel load.
  4. 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 SFTTrainer for 968 steps.
  5. 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.
  6. 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.
  7. 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.

Unsloth/Fintunning_lamma.pylines 12–36 · Unsloth model load and LoRA
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
)
Unsloth/Fintunning_lamma.pylines 39–64 · Alpaca formatting with 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:
{}"""

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,)
Unsloth/Fintunning_lamma.pylines 66–95 · SFTTrainer with packing
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/Copy_of_Llama3_1_(8B)_Alpaca.ipynbcell 10 output · trainer.train() on a Tesla T4
==((====))==  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)
Refusal collapse

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.

Adapterr / αEpochsGrad accumStepsLoss, first → lastSize
fine_tuned_llama_torchtune8 / 16581506.44 → 0.08613.6 MB
…_V_18 / 16581506.45 → 0.085 (re-run)13.6 MB
…_V_210 / 3210102406.39 → 0.03117.1 MB
…_V_312 / 3215123005.42 → 0.01320.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.

Torchtune/lamma_ft.pylines 47–73, 81–89 · lowercase template, max-length padding, default targets
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.

Torchtune/Inference_Lamma_FT.pylines 7–16, 22–38 · correct adapter load and the domain 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 prompt
Template drift between training and inference

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

Source .docx python-docx → 70 KB .txt 4 Groq generators RecursiveCharacterTextSplitter llama-3.3-70b-versatile JsonOutputParser · strip ``` fences validate each entry 4 JSON schemas {instruction,input,output} {input, podcast_style:[…]} {input, story:{b,m,e}} {input, instructions:[…]} 3,878 train · 434 val Preprocess.py detect_format → tagged text [FAQ] … Tag: Official… SFTTrainer tag → ### Instruction/input/output Llama-3.1-8B 4-bit · r 12 q,v 968 steps · 2 h 42 min loss 6.54 → 0.0435 Question generators 5,076 on-topic · 5,000 general TF-IDF + LogReg joblib · 5,000 features Inference REPL podcast? story? else classify relevant → LLM · else refuse The validation set was passed to the trainer but eval_strategy stayed "no", so no validation loss exists for this run.
The April pipeline. Solid arrows are the training path; thin arrows are the gating path that runs in parallel and meets the adapter at inference time.

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.

FineTunning Pipeline/Dataset Generation/Synthetic_data_generation.pylines 20–54 · FAQ generator in Alpaca format
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)
FineTunning Pipeline/Dataset Generation/Synthetic_data_generation.pylines 176–201 · fence stripping and per-entry validation
                # 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.

FineTunning Pipeline/Dataset PreProcesing/Preprocess.pylines 24–55 · schema detection and rendering
    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']}"
FineTunning Pipeline/Finetunning_Axolotl.pylines 33–72 · reversing the tags into task-specific instructions (plain HF Trainer despite the name)
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.

FineTunning Pipeline/Finetunning_Raytune.pylines 96–137 · search space, scheduler and tuner
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.

If you want to sweep QLoRA next time

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.

FineTunning Pipeline/Final_Finetunning.pylines 83–138 · LoRA, arguments and SFTTrainer with W&B
# 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.

FineTunning Pipeline/Question_Classifier/Logistic_Regression.pylines 17–47
    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)
FineTunning Pipeline/Inference_Finetunned_model.pylines 159–178 · routing by keyword, then by classifier
        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.

FINETUNING_ACCERLATE/config.pylines 30–75 · the dictionary schema that became training_config.yaml
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
    }
}
FINETUNING_ACCERLATE/fine_tuning.pylines 121–160 · model initialisation in the fine-tuner class
    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.

FINETUNING_ACCERLATE/logs/commercient_20250602-083322.logthe recurring failure
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_checkpoint
What happened on 2 June What works import torch … os.environ["CUDA_VISIBLE_DEVICES"] = "4,5" set after torch initialised → PyTorch still sees 6 GPUs 4-bit Gemma-2-9B device_map={'': 4} → cuda:4 Trainer args.device = cuda:0 model.to(cuda:0) ✗ quantised weights cannot be moved between devices Accelerator() was created but the script ran as plain `python`, so num_processes was always 1. os.environ["CUDA_VISIBLE_DEVICES"] = "4" … import torch set before import → exactly one visible GPU, index 0 4-bit Gemma-2-9B device_map={'': 0} → cuda:0 Trainer args.device = cuda:0 same device ✓ trainer.train() one process, one GPU; pick the GPU with pynvml first For true multi-GPU data parallelism, launch with `accelerate launch` and give each rank its own device.
Why bitsandbytes refused to train. A quantised model must already live on the device the Trainer will use. The production pipeline solved this the way the right-hand column shows: select a free GPU with pynvml, expose only that one, and load with device_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

01

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–18
02

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

Unsloth/outputs/checkpoint-60/trainer_state.json (epoch 60.0)
03

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.

Torchtune/lamma_ft.py:82 · FineTunning Pipeline/Final_Finetunning.py:87
04

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.

Torchtune/lamma_ft.py:53 vs Torchtune/Inference_Lamma_FT.py:30
05

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.

Torchtune/lamma_ft.py:56 · FINETUNING_ACCERLATE/fine_tuning.py:216
06

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.

FINETUNING_ACCERLATE/main.py:20 · FineTunning Pipeline/screenlog.0
07

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:101
08

Persist 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.py
09

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

Torchtune/ · FineTunning Pipeline/Finetunning_Axolotl.py · W&B metadata

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. 1–3 MayBulk generation with QwQ-32B. Four generator scripts run in a screen session for three days; per-chunk counts are cut from 225 to 25 after the model leaks its reasoning and returns error objects.
  2. 14 MayGemma v_2. unsloth/gemma-2-9b-it QLoRA 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.
  3. 2–3 JunAxolotl configs. 149,583 records converted to ShareGPT conversations; YAML and DeepSpeed ZeRO-2 configs generated; never trained.
  4. 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.
  5. 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.
  6. 10 Jun – 15 JulRow-wise generation pipeline. CSV input, token counting, UUID run ids, per-customer prompt customisation, atomic writes, bash wrapper.
  7. 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.
  8. 23 JulServing. FastAPI /generate with 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.

Finetuning_Pipeline_30000/DATASET GENERATION/FAQs_Generation.pylines 240–281 · the FAQ contract
        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.

Finetuning_Pipeline_30000/DATASET GENERATION/FAQs_Generation.pylines 121–152, 162–178 · outer-list slice, fence strip, regex salvage
        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:

AttemptRequested per chunkOutcome
FAQ, 1 May 09:50201,821 records in 1 h 44 min; 4 chunks "not valid JSON after cleaning"
Podcast, 1 May 10:12801,422 records; model returned {"error": "Insufficient information …"} for many chunks
FAQ, 1 May 22:352251,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:20125–2250 records: 46 × HTTP 400 from vLLM, prompt plus max_tokens of 40–60k exceeded the server context
Groq structured output, 2 May 05:52225291 records total; tool-call JSON too large, "Failed to call a function"; abandoned
Podcast, 2 May 07:21306,524 records in 7 h 33 min at 86 s per chunk
FAQ, 2 May 23:03252,820 records in 2 h 37 min, mean 24.96 per chunk, one salvage. The best-behaved run
Prompting a reasoning model for bulk JSON

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.

COMMERCIENT_CLAUD_FINETUNING/configs/llama3.1_8b.ymlthe keys that matter
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 EOS

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

COMMERCIENT_CLAUDE_FINETUNING/src/data_processing_2.pyconversation construction without a system role
        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
             continue

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

COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.pylines 288–284 · dynamic target discovery
    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_modules

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

COMMERCIENT_CLAUDE_FINETUNING/src/fine_tuning.pylines 427–458, 606–616 · callbacks and the error checkpoint
    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 e

The 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 accelerate with 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: TrainingArguments has no num_cycles parameter (the YAML still carries it); an SFTTrainer.__init__ signature mismatch; a wandb finish(reason=) that does not exist.
  • Tensor-parallel incompatibility: 'DTensor' object has no attribute 'compress_statistics' when peft met a bitsandbytes model sharded by tp_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".

MeasureValueNote
Scheduled steps17,9493 epochs at effective batch 20
Steps completed12,4002.07 epochs; stopped by EarlyStoppingCallback
Wall time117.2 h421,974 s; 0.043 steps/s, about 23 s per optimizer step
Training loss3.80 → 0.73noisy, logged every 5 steps; mean over the run 0.975
Eval loss2.21 → 0.913best at step 11,900; the last five evaluations within 0.003 of it
Eval cost~48 minper evaluation of 29,917 samples at eval batch 2, every 100 steps
GPU memory5.6 GBallocated, steady; 7.8–14 GB reserved
Output432 MBfinal_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.

Stage 1 · generation customer CSV PageContent column pipeline_rowwise.sh --source --customer --api-url … pipeline_rowwise.py tiktoken count · chunk > 31k tokens UUID run id → run_ids.json per-type prompt customisation 4 generator modules QwQ-32B via LlamaWrapper *_row_wise() · one call per row .tmp write → os.replace SYNTHETIC_DATA/<customer> JSON records + metadata field Stage 2 · training run_finetuning.sh conda activate · --customer setup.py two subprocesses src/data_processing.py copy Commercient_<type>_<id>.json → {messages: [user, assistant]} 80/20 split → train/validation src/fine_tuning.py pynvml: GPUs with ≥ 18 GB free reference trainer + status JSON os._exit(1) on any failure models/final_adapter + tokenizer + checkpoints /mnt/cnn-shr/transcriber/SyntheticDataFinetuning/<customer>/ {training_config.yaml, data/raw, data/processed, models/} · /mnt/cnn-shr/transcriber/log/ · /mnt/cnn-shr/transcriber/out/<customer>.json everything stage 2 reads and writes lives on the network share, keyed by customer; the status JSON was meant to be polled by the Flask UI
The July pipeline as wired. The two stages were never joined end to end: stage 2 expects four per-type files named with a finetuning id, while the final row-wise generators write a single per-customer file. Only the June pre-row-wise output matched, and that is the id every smoke test used.

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.

COMMERCIENT_GEMMA_FINETUNING/GEMMA_FINETUNING/src/fine_tuning.pylines 109–143, 149–157
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 exit

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

COMMERCIENT_GEMMA_FINETUNING/COMMERCIENT_DATASET_GENERATION/pipeline_rowwise.pylines 223–262 · chunking by token count
    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
COMMERCIENT_GEMMA_FINETUNING/COMMERCIENT_DATASET_GENERATION/FAQs_Generation.pylines 397–434 · the row-wise prompt with a customer hook
    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 final committed state does not run end to end

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 as JSON columns and an INSERT OR REPLACE upsert. 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 with os._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.

Streamlit chat POST latest message FastAPI /generate :8001 (UI points at :8002) prompt · max_new_tokens temperature · top_p Embed + retrieve text-embedding-3-small Pinecone index "commercient" all namespaces · top 10 each top score ≥ 0.4 ? cosine similarity Gemma-2-9B + LoRA chat template · merged · sample Canned refusal "I can only answer questions…" Retrieved passages are discarded after scoring. Adding them to the prompt would turn this gate into full RAG and reduce hallucination on facts the adapter never saw.
Gate first, generate second. The 0.4 threshold on OpenAI cosine scores is permissive; the design still removes the need to train and maintain a classifier, and the same index can later supply context.
Finetuning_Pipeline_30000/api_commercient_ansh_model.pylines 164–221 · the generate handler
@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

01

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_GENERATION
02

Use 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.py
03

Size 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.json
04

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

COMMERCIENT_CLAUDE_FINETUNING/logs/training.log
05

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.

COMMERCIENT_CLAUDE_FINETUNING/models/fine_tuned/20250606_040433
06

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.

wandb_projects: COMMERCIENT_CLAUDE_FINETUNING, 4 Jun
07

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.

Finetuning_Pipeline_30000/All_Commercient_Data_Finetune_Gemma_version_1
08

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.py
09

Keep 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.py

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

Train and eval loss, run 20250606_072330. Hover or focus the chart to read values; the table view below it lists every evaluation. Values from 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.

Training loss per step, log scale, coloured by era. Eval loss, where it exists, is the dark line with markers. The two February panels are the 33 to 69-step runs on a few dozen records; the Unsloth panel is 80 epochs over 87 records; the three April panels are the tagged-text runs on Llama 3.1; the last three are Gemma 2 9B.

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.

Training records per run, log scale. Counts are from logs where stated and otherwise estimated from steps × batch × accumulation ÷ epochs.

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.

COMMERCIENT_CLAUDE_FINETUNING (Jun 3–6)168 · 1 completed COMMERCIENT_GEMMA_FINETUNING (Jun 12 – Jul 28)94 · 39 smoke tests FINETUNING_ACCERLATE (Jun 2)29 · 0 completed FineTunning Pipeline (Apr 15–22)11 · 2 completed top-level wandb/ (Feb 25–27)7 · 4 "completed" with NaN loss Torchtune, Ditillation2

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, SFTTrainer signature, wandb.finish(reason=), missing model_name key, 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

RunDateEraBase modelr / αlrStepsLast train lossBest evalOutcome
fine_tunned_llama3_1_qlora_ansh_v_5_TAG26 Feb1Llama-3.1-8B8 / 322e-4691.30completed, no eval
fine_tuned_llama3_qlora_ansh_v_727 Feb1Llama-3.1-8B10 / 162e-4600.23completed, 1.16 GB adapter
Unsloth/outputs (bilingual)13 Mar2Llama-3.1-8B-Instruct (Unsloth 4-bit)16 / 162e-4800.01880 epochs, memorised
Torchtune/fine_tuned_llama_torchtune_V_327 Mar2Llama-3.1-8B12 / 322e-43000.01315 epochs, memorised
FineTunning Pipeline/Fine_tunned_ALL_SYNC_Data17 Apr2Llama-3.1-8B12 / 322e-48450.039completed in 38 min
FineTunning Pipeline/fine_tuned_model → Commercient_fine_tuned_model22 Apr2Llama-3.1-8B12 / 322e-49680.044completed in 2 h 42 min after two OOMs
Finetuning_Pipeline_30000/fine_tuned_gemma_v_214 May2unsloth/gemma-2-9b-it16 / 321e-41,300 / 6,7650.610.815stopped at 19% · the served adapter
All_Commercient_Data_Finetune_Gemma_version_14 Jun3unsloth/gemma-2-9b-it8 / 161e-5?adapter saved, no loss log survives
W&B hbdtwvv95 Jun3gemma-2-9b-it16 / 320.0250878,115diverged · learning-rate typo
gemma-2-9b-it-20250605_0629045 Jun3gemma-2-9b-it16 / 322e-5500 / 17,9491.221.14crashed · corrupt checkpoint write
gemma-2-9b-it-20250605_093320, 20250606_0406005–6 Jun3gemma-2-9b-it32 / 641e-5125 / 18,6951.882.10OOM · seq 4096, batch 4
gemma-2-9b-it-20250606_0505216 Jun3gemma-2-9b-it32 / 641e-5275 / 17,9491.301.53killed · relaunched two hours later
gemma-2-9b-it-20250606_0723306–11 Jun3gemma-2-9b-it32 / 641e-512,400 / 17,9490.730.913completed · early stopped after 117 h
TEST customer smoke tests (38 runs)12 Jun – 28 Jul3gemma-2-9b-it32 / 641e-533.706 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.

FebMarAprMayJunJul 1 · Embedding cosine question vs one seed sentence all-mpnet-base-v2 · ≥ 0.5 26 Feb · outside the model 2 · Tag-conditioned prompt "answer only tagged content, otherwise remain silent" 27 Feb · inside the prompt 3 · Rejection sampling refusal sentence in prompt + 30 keywords over the answer, 3 tries 5 Mar · after generation 4 · Refusals in the data 85 of 247 records refuse → model refuses on-topic too 10 Mar · inside the weights 5 · Keyword list 12 words over the question "sync", "crm", "erp", "sales"… 27 Mar · before generation 6 · TF-IDF + LogReg 5,076 on-topic vs 5,000 general generated questions · joblib 29 Apr · learned, then commented out 7 · Prompt rules numbered rules + examples of out-of-domain questions 30 Apr · inside the prompt 8 · BERT classifier 149,583 positives vs 1,000 (then 10) templated negatives 2 Jun · never finished training 9 · Retrieval threshold OpenAI embedding → Pinecone top score ≥ 0.4 over all namespaces 23 Jul · outside the model, no training
The gating lineage. Approaches above the line check the question before generation; below the line they act inside the prompt, inside the weights, or on the answer after generation. The last approach closes the loop back to the first: embedding similarity, but against a whole document index instead of one sentence.
#ApproachWhere it actsWhat it actually checksHow it failed
1Cosine to seed sentencebefore generationSemantic similarity to a single 20-word sentenceOne sentence cannot represent a domain; thresholds from mean-pooled raw embeddings do not transfer
2Tag-conditioned system promptinside the promptAsks a 69-step base model to "remain silent"Silence is not a learnable behaviour from tagged text; the emptiness check was unreachable in code
3Refusal + keyword rejection samplingafter generationWhether the answer contains a refusal phrase or a domain wordFilters the answer, not the question; generic keywords make it permissive; three attempts triple latency
4Refusal examples in training datainside the weightsNothing explicit; hopes the model learns the boundary34% refusals over 30 epochs produced refusal collapse on on-topic questions
5Keyword list over the questionbefore generationSubstring match on 12 wordsMisses paraphrases, admits any sentence containing "sales"
6TF-IDF + logistic regressionbefore generationWord distribution of the questionLearned vocabulary, not intent; balanced but with easy negatives; accuracy never recorded; commented out after five days
7Rules inside the promptinside the promptModel self-judges against listed examplesDepends on the model's compliance; the fallback check compared the answer to the prompt string
8BERT classifierbefore generationWould have learned "is this a question?" from 99.3% positive dataNever trained to completion; the negative set shrank to ten hand-written sentences; bypassed with if True:
9Retrieval thresholdbefore generationWhether any indexed company document is similar enough to the question0.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.txt already 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

ShapeFirst seenExampleUsed by
Raw sentences24 FebDid 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 sentinel3 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 text10 Apr[PODCAST]\nHost: We're exploring…\nGuest: One of the best features…\nTag: Official Commercient ContentFineTunning 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:

COMMERCIENT_CLAUDE_FINETUNING/data/raw/All_Commercient_*.jsonone record per type, abbreviated
{"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.

AdapterTrained onPrompted withMatch?
v, v_4, v_5raw sentences, no templateraw questionconsistent (but nothing to learn)
Ditillation llama3_8b_faqQuestion: …\nAnswer: … ###END###system sentence + Question: …\nAnswer:close
Unsloth Llama / QwenAlpaca with preamble, ### Instruction / ### Input / ### Response + EOSsame Alpaca, input tag lowercase "content"near miss
Torchtune V_2### Instruction / ### input / ### output, no preamble, no EOSAlpaca preamble + ### Input / ### Responsemismatch
Commercient_fine_tuned_model (Apr)### Instruction / ### input:\nTag: … / ### outputsame layout plus a rules block and conversation historypartial
Gemma v_2 (served)Alpaca ### Instruction / ### Input / ### ResponseGemma chat template with "\n\nQuestion: …"mismatch
FINETUNING_ACCERLATE inference_without_classifierAlpacaGemma <start_of_turn> tags, or ChatML tags labelled "Llama-style"mismatch
gemma-2-9b-it-20250606_072330Gemma chat template via SFTTrainer, instruction in the user turnGradio app: chat template, bare questionpartial (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.

Unsloth/Fintunning_lamma.py · COMMERCIENT_CLAUDE_FINETUNING/fine_tunning_accelerate.pythe two template families side by side
# 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 of open(…, '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_token plus 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_modules explicitly and read the saved adapter_config.json afterwards. peft's default silently trained only q and v in six adapters.
  • Never target lm_head or embed_tokens unless 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 the load_in_4bit=True shorthand.
  • Call prepare_model_for_kbit_training before get_peft_model and 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_checkpointing and group_by_length on for memory; sequence length 1024 fit two 9B runs where 2048 and 4096 did not.
  • Add early stopping with load_best_model_at_end and 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 before torch.cuda is touched), then load with device_map={"": 0}.
  • One process, one GPU for QLoRA; device_map="auto" across cards is sharding, not speed. For real data parallelism use accelerate launch with one model copy per rank.
  • Check nvidia-smi before 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 program only saved the file name; three of the trainers behind saved adapters are gone) and write hyperparameters.json from 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 pass do_sample=True when 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.py as 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.

FolderFiles worth openingSkip
/ (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.binweights, 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.txtthinc-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.jsonunsloth_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.jsontemp.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.jsonthe 50 per-run generation logs, temp.py
COMMERCIENT_CLAUD_FINETUNING/finetuning_pipeline.py · configs/llama3.1_8b.yml, gemma2_9b.yml, zero2.jsonaxolotl/ (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.pythe 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.pypipeline.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_end and 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.