{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Gena Notebooks: TuluEng100 LoRA experiment\n",
    "\n",
    "A manual, inspectable fine-tuning walkthrough using the public TuluEng100 English–Tulu sentence-pair dataset.\n",
    "\n",
    "**Important limits:** TuluEng100 contains 100 pairs. The project owner reports reviewing it as a Tulu speaker, but there is no independent second review or per-record correction log. This notebook demonstrates a training workflow; it does not establish that the resulting adapter can translate Tulu reliably. Do not use it for consequential translation.\n",
    "\n",
    "**Data and session boundary:** Use only the public sample in this notebook. Gena provides downloadable files but does not host or retain this notebook session. The notebook runtime you choose will handle the files and training session. The notebook downloads open model weights and does not call a model inference API.\n",
    "\n",
    "**Attribution:** TuluEng100 is provided under CC-BY-4.0. Preserve attribution and review the license before reuse. Source: https://huggingface.co/datasets/tuluai/TuluEng100. The downloaded JSON contains its source revision and update date; keep those values with your experiment notes.\n",
    "\n",
    "The example model is `Qwen/Qwen3-0.6B`, distributed under Apache-2.0. Its model card describes support for 100+ languages, but Tulu-specific quality has not been verified. Review the model card and license before use: https://huggingface.co/Qwen/Qwen3-0.6B."
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 1. Choose a GPU runtime\n",
    "\n",
    "Select a GPU runtime in your notebook environment before running the training cells. This experiment uses LoRA on a small open-weight model, not full fine-tuning. It still downloads model weights and Python packages, so the runtime needs internet access. Free runtime availability and quotas depend on the service you choose."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%pip -q install \"transformers>=4.51.0,<5.0.0\" \"datasets>=3.0,<5.0\" \"peft>=0.15.0,<1.0\" \"accelerate>=1.0,<2.0\""
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import torch\n",
    "\n",
    "if not torch.cuda.is_available():\n",
    "    raise RuntimeError(\"A CUDA GPU is required for this training walkthrough. Select a GPU runtime and rerun this cell.\")\n",
    "\n",
    "print(\"GPU:\", torch.cuda.get_device_name(0))\n",
    "print(\"CUDA:\", torch.version.cuda)\n",
    "print(\"BF16 supported:\", torch.cuda.is_bf16_supported())"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 2. Load and inspect the public dataset\n",
    "\n",
    "Download `tulu-eng-100.json` from the Gena Notebooks page. In a hosted notebook, upload the file when prompted. In another environment, set `DATA_PATH` to the downloaded file path."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import json\n",
    "from pathlib import Path\n",
    "\n",
    "DATA_PATH = Path(\"tulu-eng-100.json\")\n",
    "\n",
    "if not DATA_PATH.exists():\n",
    "    try:\n",
    "        from google.colab import files\n",
    "        uploaded = files.upload()\n",
    "        if uploaded:\n",
    "            DATA_PATH = Path(next(iter(uploaded)))\n",
    "    except ImportError:\n",
    "        pass\n",
    "\n",
    "if not DATA_PATH.exists() and Path(\"/kaggle/input\").exists():\n",
    "    matches = list(Path(\"/kaggle/input\").rglob(\"tulu-eng-100.json\"))\n",
    "    if matches:\n",
    "        DATA_PATH = matches[0]\n",
    "\n",
    "if not DATA_PATH.exists():\n",
    "    raise FileNotFoundError(\"Could not find tulu-eng-100.json. Upload it or update DATA_PATH.\")\n",
    "\n",
    "with DATA_PATH.open(encoding=\"utf-8\") as file:\n",
    "    payload = json.load(file)\n",
    "\n",
    "pairs = [\n",
    "    {\"english\": row[\"english\"].strip(), \"tulu\": row[\"tulu\"].strip()}\n",
    "    for row in payload.get(\"examples\", [])\n",
    "    if isinstance(row.get(\"english\"), str)\n",
    "    and isinstance(row.get(\"tulu\"), str)\n",
    "    and row[\"english\"].strip()\n",
    "    and row[\"tulu\"].strip()\n",
    "]\n",
    "\n",
    "if not pairs:\n",
    "    raise ValueError(\"No valid English–Tulu pairs found. Check the JSON file schema.\")\n",
    "\n",
    "print(\"Source:\", payload.get(\"source\", \"not provided\"))\n",
    "print(\"License:\", payload.get(\"license\", \"not provided\"))\n",
    "print(\"Source revision:\", payload.get(\"sourceRevision\", \"not provided\"))\n",
    "print(\"Source updated:\", payload.get(\"sourceUpdatedAt\", \"not provided\"))\n",
    "print(\"Valid pairs:\", len(pairs))\n",
    "print(\"Example:\", pairs[0])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 3. Split pairs before creating translation directions\n",
    "\n",
    "The held-out split is made from original pairs first, so a pair and its reverse direction cannot land on opposite sides. A fixed random seed makes the split repeatable. With only 100 pairs, similar sentence patterns can still occur in both splits, so evaluation is only a smoke test."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import random\n",
    "\n",
    "shuffled_pairs = pairs.copy()\n",
    "random.Random(42).shuffle(shuffled_pairs)\n",
    "split_at = max(1, int(len(shuffled_pairs) * 0.8))\n",
    "train_pairs = shuffled_pairs[:split_at]\n",
    "eval_pairs = shuffled_pairs[split_at:]\n",
    "\n",
    "def make_directions(source_pairs):\n",
    "    rows = []\n",
    "    for pair in source_pairs:\n",
    "        rows.append({\"source\": \"English\", \"target\": \"Tulu\", \"sentence\": pair[\"english\"], \"answer\": pair[\"tulu\"]})\n",
    "        rows.append({\"source\": \"Tulu\", \"target\": \"English\", \"sentence\": pair[\"tulu\"], \"answer\": pair[\"english\"]})\n",
    "    return rows\n",
    "\n",
    "train_rows = make_directions(train_pairs)\n",
    "eval_rows = make_directions(eval_pairs)\n",
    "print(f\"Training examples: {len(train_rows)}; held-out examples: {len(eval_rows)}\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 4. Load the base model and add a LoRA adapter\n",
    "\n",
    "This step downloads open weights into your selected runtime. LoRA trains a small adapter while leaving the base model weights frozen. It is not a Gena-hosted service."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from transformers import AutoModelForCausalLM, AutoTokenizer\n",
    "from peft import LoraConfig, get_peft_model\n",
    "\n",
    "MODEL_ID = \"Qwen/Qwen3-0.6B\"\n",
    "use_bf16 = torch.cuda.is_bf16_supported()\n",
    "model_dtype = torch.bfloat16 if use_bf16 else torch.float16\n",
    "\n",
    "tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)\n",
    "if tokenizer.pad_token is None:\n",
    "    tokenizer.pad_token = tokenizer.eos_token\n",
    "\n",
    "model = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype=model_dtype)\n",
    "model.to(\"cuda\")\n",
    "model.config.use_cache = False\n",
    "\n",
    "lora_config = LoraConfig(\n",
    "    task_type=\"CAUSAL_LM\",\n",
    "    r=8,\n",
    "    lora_alpha=16,\n",
    "    lora_dropout=0.05,\n",
    "    target_modules=[\"q_proj\", \"k_proj\", \"v_proj\", \"o_proj\", \"gate_proj\", \"up_proj\", \"down_proj\"],\n",
    ")\n",
    "model = get_peft_model(model, lora_config)\n",
    "model.print_trainable_parameters()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 5. Prepare supervised examples\n",
    "\n",
    "Both directions are included. The model is asked for one translation, but the training objective includes the chat-formatted prompt and response. This tiny experiment is not a complete translation system."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from datasets import Dataset\n",
    "\n",
    "def render_example(row):\n",
    "    instruction = (\n",
    "        f\"Translate from {row['source']} to {row['target']}. \"\n",
    "        \"Use Tulu in Latin script when Tulu is the target. Return only the translation.\\n\\n\"\n",
    "        f\"Sentence: {row['sentence']}\"\n",
    "    )\n",
    "    messages = [\n",
    "        {\"role\": \"user\", \"content\": instruction},\n",
    "        {\"role\": \"assistant\", \"content\": row[\"answer\"]},\n",
    "    ]\n",
    "    return tokenizer.apply_chat_template(\n",
    "        messages,\n",
    "        tokenize=False,\n",
    "        add_generation_prompt=False,\n",
    "        enable_thinking=False,\n",
    "    )\n",
    "\n",
    "train_text = Dataset.from_dict({\"text\": [render_example(row) for row in train_rows]})\n",
    "eval_text = Dataset.from_dict({\"text\": [render_example(row) for row in eval_rows]})\n",
    "\n",
    "def tokenize_batch(batch):\n",
    "    return tokenizer(batch[\"text\"], truncation=True, max_length=256)\n",
    "\n",
    "train_dataset = train_text.map(tokenize_batch, batched=True, remove_columns=[\"text\"])\n",
    "eval_dataset = eval_text.map(tokenize_batch, batched=True, remove_columns=[\"text\"])\n",
    "print(\"Tokenized training rows:\", len(train_dataset))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from transformers import DataCollatorForLanguageModeling, Trainer, TrainingArguments\n",
    "\n",
    "training_args = TrainingArguments(\n",
    "    output_dir=\"./tulueng100-lora-run\",\n",
    "    num_train_epochs=3,\n",
    "    per_device_train_batch_size=2,\n",
    "    per_device_eval_batch_size=2,\n",
    "    gradient_accumulation_steps=4,\n",
    "    learning_rate=2e-4,\n",
    "    warmup_ratio=0.03,\n",
    "    weight_decay=0.01,\n",
    "    logging_steps=5,\n",
    "    eval_strategy=\"epoch\",\n",
    "    save_strategy=\"no\",\n",
    "    fp16=not use_bf16,\n",
    "    bf16=use_bf16,\n",
    "    report_to=\"none\",\n",
    "    seed=42,\n",
    ")\n",
    "\n",
    "trainer = Trainer(\n",
    "    model=model,\n",
    "    args=training_args,\n",
    "    train_dataset=train_dataset,\n",
    "    eval_dataset=eval_dataset,\n",
    "    data_collator=DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False),\n",
    "    processing_class=tokenizer,\n",
    ")\n",
    "\n",
    "train_result = trainer.train()\n",
    "print(\"Training loss:\", train_result.training_loss)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 6. Inspect held-out examples\n",
    "\n",
    "The loss below is not a translation-quality score. Inspect the generated examples against the references, and have fluent speakers review any future dataset before drawing conclusions."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "model.config.use_cache = True\n",
    "print(trainer.evaluate())\n",
    "\n",
    "def generate_translation(row):\n",
    "    instruction = (\n",
    "        f\"Translate from {row['source']} to {row['target']}. \"\n",
    "        \"Use Tulu in Latin script when Tulu is the target. Return only the translation.\\n\\n\"\n",
    "        f\"Sentence: {row['sentence']}\"\n",
    "    )\n",
    "    prompt = tokenizer.apply_chat_template(\n",
    "        [{\"role\": \"user\", \"content\": instruction}],\n",
    "        tokenize=False,\n",
    "        add_generation_prompt=True,\n",
    "        enable_thinking=False,\n",
    "    )\n",
    "    inputs = tokenizer(prompt, return_tensors=\"pt\").to(model.device)\n",
    "    with torch.no_grad():\n",
    "        generated = model.generate(\n",
    "            **inputs,\n",
    "            max_new_tokens=64,\n",
    "            do_sample=False,\n",
    "            pad_token_id=tokenizer.eos_token_id,\n",
    "        )\n",
    "    completion = generated[0][inputs.input_ids.shape[1]:]\n",
    "    return tokenizer.decode(completion, skip_special_tokens=True).strip()\n",
    "\n",
    "for row in eval_rows[:6]:\n",
    "    print(\"Source:\", row[\"source\"], \"to\", row[\"target\"], \"|\", row[\"sentence\"])\n",
    "    print(\"Reference:\", row[\"answer\"])\n",
    "    print(\"Model:\", generate_translation(row))\n",
    "    print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 7. Save the adapter\n",
    "\n",
    "Save the adapter separately from the base model. Keep the dataset attribution and model license with any shared experiment. The adapter is an experimental artifact, not a verified translator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "from pathlib import Path\n",
    "import shutil\n",
    "\n",
    "adapter_dir = Path(\"tulueng100-lora-adapter\")\n",
    "model.save_pretrained(adapter_dir)\n",
    "tokenizer.save_pretrained(adapter_dir)\n",
    "archive_path = shutil.make_archive(str(adapter_dir), \"zip\", root_dir=adapter_dir)\n",
    "print(\"Adapter archive:\", archive_path)\n",
    "\n",
    "try:\n",
    "    from google.colab import files\n",
    "    files.download(archive_path)\n",
    "except ImportError:\n",
    "    print(\"The adapter archive is in the notebook working directory. Download it using your runtime's file browser.\")"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## What this experiment does not show\n",
    "\n",
    "- It does not prove that the model understands Tulu grammar or translates unseen sentences accurately.\n",
    "- The 100 source pairs have not been independently verified; output quality may reflect data errors or spelling variation.\n",
    "- Random held-out examples can share patterns with training data and are not a substitute for a larger, speaker-reviewed test set.\n",
    "- Gena does not run, monitor, or retain the notebook session. Review the terms and privacy practices of the runtime you choose.\n",
    "- Model weights and datasets have separate licenses. Review both before distributing an adapter or using it commercially."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "name": "python",
   "version": "3"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 5
}
