Image-Text-to-Image
Transformers
PEFT
English
knowledge-graph
information-extraction
scene-graph
image-to-graph
structured-extraction
opengraph
gemma4
lora
mcp
Instructions to use OpenGraph-AI/opengraph-image-gemma4-e4b-v1 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use OpenGraph-AI/opengraph-image-gemma4-e4b-v1 with Transformers:
# pip install -U transformers accelerate # Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("OpenGraph-AI/opengraph-image-gemma4-e4b-v1", device_map="auto") - PEFT
How to use OpenGraph-AI/opengraph-image-gemma4-e4b-v1 with PEFT:
Task type is invalid.
- Notebooks
- Google Colab
- Kaggle
Upload opengraph_image_gemma4_e4b_finetune_starter.ipynb
Browse files
opengraph_image_gemma4_e4b_finetune_starter.ipynb
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"cells": [
|
| 3 |
+
{
|
| 4 |
+
"cell_type": "markdown",
|
| 5 |
+
"id": "e681502a",
|
| 6 |
+
"metadata": {},
|
| 7 |
+
"source": [
|
| 8 |
+
"# OpenGraph-Image — Fine-Tuning Gemma 4 E4B (QLoRA) — Starter Notebook\n",
|
| 9 |
+
"\n",
|
| 10 |
+
"This notebook fine-tunes **Gemma 4 E4B** to turn an image into a valid `graph.json` knowledge graph, using QLoRA — following [Google's official Gemma 4 vision QLoRA guide](https://ai.google.dev/gemma/docs/core/huggingface_vision_finetune_qlora).\n",
|
| 11 |
+
"\n",
|
| 12 |
+
"**Before you run anything, confirm the guidebook prerequisites are done:**\n",
|
| 13 |
+
"\n",
|
| 14 |
+
"1. You created / joined the `OpenGraphAI` Hugging Face org.\n",
|
| 15 |
+
"2. You accepted the **Gemma 4 license** on the [E4B model page](https://huggingface.co/google/gemma-4-E4B-it) while logged in.\n",
|
| 16 |
+
"3. This Colab session has an **L4 GPU** selected: `Runtime → Change runtime type → L4`.\n",
|
| 17 |
+
"4. Your **write-scope HF token** is stored in Colab Secrets (🔑 icon) as `HF_TOKEN`.\n",
|
| 18 |
+
"\n",
|
| 19 |
+
"> **Placeholders:** anything written as `<<LIKE_THIS>>` or `[More Information Needed]` is for the team to fill in. Search the notebook for `<<` to find them all.\n",
|
| 20 |
+
"\n",
|
| 21 |
+
"> **Note on hyperparameters:** the values here are Google's published defaults. **Do not change them on your first real run** — get a baseline number first, then tune."
|
| 22 |
+
]
|
| 23 |
+
},
|
| 24 |
+
{
|
| 25 |
+
"cell_type": "markdown",
|
| 26 |
+
"id": "58dcb06f",
|
| 27 |
+
"metadata": {},
|
| 28 |
+
"source": [
|
| 29 |
+
"## 1. Install dependencies\n",
|
| 30 |
+
"\n",
|
| 31 |
+
"Installs the training stack. `transformers>=5.10.1` is required "
|
| 32 |
+
]
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"cell_type": "code",
|
| 36 |
+
"execution_count": null,
|
| 37 |
+
"id": "2403b734",
|
| 38 |
+
"metadata": {},
|
| 39 |
+
"outputs": [],
|
| 40 |
+
"source": [
|
| 41 |
+
"!pip install -q -U \"transformers>=5.10.1\" \"trl\" \"peft\" \"bitsandbytes\" \"accelerate\" \"datasets\" \"Pillow\""
|
| 42 |
+
]
|
| 43 |
+
},
|
| 44 |
+
{
|
| 45 |
+
"cell_type": "markdown",
|
| 46 |
+
"id": "0d970981",
|
| 47 |
+
"metadata": {},
|
| 48 |
+
"source": [
|
| 49 |
+
"## 2. Authenticate with Hugging Face\n",
|
| 50 |
+
"\n",
|
| 51 |
+
"Pulls your `HF_TOKEN` from Colab Secrets and logs in. This is what lets you (a) download the gated Gemma model and (b) push your trained adapter back to the org. If you haven't added the secret yet: click the 🔑 icon in the left sidebar, add `HF_TOKEN`, and toggle \"Notebook access\" on."
|
| 52 |
+
]
|
| 53 |
+
},
|
| 54 |
+
{
|
| 55 |
+
"cell_type": "code",
|
| 56 |
+
"execution_count": null,
|
| 57 |
+
"id": "ed661ea8",
|
| 58 |
+
"metadata": {},
|
| 59 |
+
"outputs": [],
|
| 60 |
+
"source": [
|
| 61 |
+
"from google.colab import userdata\n",
|
| 62 |
+
"from huggingface_hub import login\n",
|
| 63 |
+
"\n",
|
| 64 |
+
"login(token=userdata.get(\"HF_TOKEN\"))\n",
|
| 65 |
+
"print(\"Logged in to Hugging Face.\")"
|
| 66 |
+
]
|
| 67 |
+
},
|
| 68 |
+
{
|
| 69 |
+
"cell_type": "markdown",
|
| 70 |
+
"id": "9ebeb277",
|
| 71 |
+
"metadata": {},
|
| 72 |
+
"source": [
|
| 73 |
+
"## 3. Smoke test — is this GPU actually usable?\n",
|
| 74 |
+
"\n",
|
| 75 |
+
"Gemma 4 QLoRA training needs a **bfloat16-capable GPU** (NVIDIA Ampere or newer). Compute capability `>= 8` means Ampere+ (L4 = 8.9, A100 = 8.0). A T4 (7.5) will fail this check — reconnect to an L4 runtime if it does."
|
| 76 |
+
]
|
| 77 |
+
},
|
| 78 |
+
{
|
| 79 |
+
"cell_type": "code",
|
| 80 |
+
"execution_count": null,
|
| 81 |
+
"id": "00c2d692",
|
| 82 |
+
"metadata": {},
|
| 83 |
+
"outputs": [],
|
| 84 |
+
"source": [
|
| 85 |
+
"import torch\n",
|
| 86 |
+
"\n",
|
| 87 |
+
"assert torch.cuda.is_available(), \"No GPU. Runtime -> Change runtime type -> L4.\"\n",
|
| 88 |
+
"name = torch.cuda.get_device_name(0)\n",
|
| 89 |
+
"cc = torch.cuda.get_device_capability()\n",
|
| 90 |
+
"assert cc[0] >= 8, f\"GPU {name} (cc {cc}) lacks bf16. Reconnect for an L4 or A100.\"\n",
|
| 91 |
+
"print(f\"GPU OK: {name} (compute capability {cc[0]}.{cc[1]})\")"
|
| 92 |
+
]
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"cell_type": "markdown",
|
| 96 |
+
"id": "a46ddabc",
|
| 97 |
+
"metadata": {},
|
| 98 |
+
"source": [
|
| 99 |
+
"## 4. Configuration\n",
|
| 100 |
+
"\n",
|
| 101 |
+
"All the knobs in one place. Fill the placeholders, then the rest of the notebook reads from these constants."
|
| 102 |
+
]
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"cell_type": "code",
|
| 106 |
+
"execution_count": null,
|
| 107 |
+
"id": "e26190c6",
|
| 108 |
+
"metadata": {},
|
| 109 |
+
"outputs": [],
|
| 110 |
+
"source": [
|
| 111 |
+
"# --- Models -----------------------------------------------------------------\n",
|
| 112 |
+
"BASE_MODEL_ID = \"google/gemma-4-E4B\" # the model we TRAIN (base, not -it)\n",
|
| 113 |
+
"PROCESSOR_ID = \"google/gemma-4-E4B-it\" # processor gives us the official chat template\n",
|
| 114 |
+
"\n",
|
| 115 |
+
"# --- Data -------------------------------------------------------------------\n",
|
| 116 |
+
"DATASET_ID = \"OpenGraphAI/opengraph-image-gold-v1\" # <<CONFIRM_OR_CHANGE>> the gold-pair dataset on the Hub\n",
|
| 117 |
+
"\n",
|
| 118 |
+
"# --- Output -----------------------------------------------------------------\n",
|
| 119 |
+
"OUTPUT_REPO = \"OpenGraphAI/opengraph-image-gemma4-e4b-v1\" # <<CONFIRM>> where the adapter is pushed\n",
|
| 120 |
+
"\n",
|
| 121 |
+
"# --- The extraction system prompt (must match production / extract.py) ------\n",
|
| 122 |
+
"SYSTEM_PROMPT = \"\"\"<<PASTE_THE_OPENGRAPH_EXTRACTION_SYSTEM_PROMPT_HERE>>\"\"\"\n",
|
| 123 |
+
"\n",
|
| 124 |
+
"USER_INSTRUCTION = \"Extract the knowledge graph from this image.\"\n",
|
| 125 |
+
"\n",
|
| 126 |
+
"# --- Quick-start / debug switch ---------------------------------------------\n",
|
| 127 |
+
"# Set True for the \"throwaway plumbing run\" (guidebook step 4.1): trains on a\n",
|
| 128 |
+
"# tiny slice for a few minutes to shake out bugs before the real run.\n",
|
| 129 |
+
"SMOKE_TEST = True\n",
|
| 130 |
+
"SMOKE_TEST_N = 100"
|
| 131 |
+
]
|
| 132 |
+
},
|
| 133 |
+
{
|
| 134 |
+
"cell_type": "markdown",
|
| 135 |
+
"id": "13bf02c8",
|
| 136 |
+
"metadata": {},
|
| 137 |
+
"source": [
|
| 138 |
+
"## 5. Load the dataset\n",
|
| 139 |
+
"\n",
|
| 140 |
+
"Loads the gold pairs from the Hub. Each row is expected to be a chat-format example with an image in the user turn and the gold `graph.json` (as a string) in the assistant turn — the structure defined in **guidebook step 2.5**.\n",
|
| 141 |
+
"\n",
|
| 142 |
+
"If `SMOKE_TEST` is on, we shrink the training split so a full pass takes minutes, not hours."
|
| 143 |
+
]
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
"cell_type": "code",
|
| 147 |
+
"execution_count": null,
|
| 148 |
+
"id": "4c366b48",
|
| 149 |
+
"metadata": {},
|
| 150 |
+
"outputs": [],
|
| 151 |
+
"source": [
|
| 152 |
+
"from datasets import load_dataset\n",
|
| 153 |
+
"\n",
|
| 154 |
+
"dataset = load_dataset(DATASET_ID)\n",
|
| 155 |
+
"print(dataset)\n",
|
| 156 |
+
"\n",
|
| 157 |
+
"train_ds = dataset[\"train\"]\n",
|
| 158 |
+
"val_ds = dataset[\"validation\"]\n",
|
| 159 |
+
"\n",
|
| 160 |
+
"if SMOKE_TEST:\n",
|
| 161 |
+
" train_ds = train_ds.select(range(min(SMOKE_TEST_N, len(train_ds))))\n",
|
| 162 |
+
" val_ds = val_ds.select(range(min(16, len(val_ds))))\n",
|
| 163 |
+
" print(f\"SMOKE_TEST on: training on {len(train_ds)} examples, validating on {len(val_ds)}.\")\n",
|
| 164 |
+
"\n",
|
| 165 |
+
"# Peek at one example's structure (without dumping a whole base64 image)\n",
|
| 166 |
+
"example = train_ds[0]\n",
|
| 167 |
+
"print(\"\\nExample keys:\", list(example.keys()))\n",
|
| 168 |
+
"print(\"Roles in messages:\", [m[\"role\"] for m in example[\"messages\"]])"
|
| 169 |
+
]
|
| 170 |
+
},
|
| 171 |
+
{
|
| 172 |
+
"cell_type": "markdown",
|
| 173 |
+
"id": "2d89d108",
|
| 174 |
+
"metadata": {},
|
| 175 |
+
"source": [
|
| 176 |
+
"## 6. Load the model + processor (4-bit)\n",
|
| 177 |
+
"\n",
|
| 178 |
+
"Loads Gemma 4 E4B in **4-bit NF4** quantization — this is the \"Q\" in QLoRA, and it's what makes an ~8B-parameter model fit in an L4's 24 GB. The frozen 4-bit model is what we attach LoRA adapters to in the next step."
|
| 179 |
+
]
|
| 180 |
+
},
|
| 181 |
+
{
|
| 182 |
+
"cell_type": "code",
|
| 183 |
+
"execution_count": null,
|
| 184 |
+
"id": "210c7259",
|
| 185 |
+
"metadata": {},
|
| 186 |
+
"outputs": [],
|
| 187 |
+
"source": [
|
| 188 |
+
"from transformers import AutoModelForImageTextToText, AutoProcessor, BitsAndBytesConfig\n",
|
| 189 |
+
"import torch\n",
|
| 190 |
+
"\n",
|
| 191 |
+
"bnb_config = BitsAndBytesConfig(\n",
|
| 192 |
+
" load_in_4bit=True,\n",
|
| 193 |
+
" bnb_4bit_use_double_quant=True,\n",
|
| 194 |
+
" bnb_4bit_quant_type=\"nf4\",\n",
|
| 195 |
+
" bnb_4bit_compute_dtype=torch.bfloat16,\n",
|
| 196 |
+
")\n",
|
| 197 |
+
"\n",
|
| 198 |
+
"model = AutoModelForImageTextToText.from_pretrained(\n",
|
| 199 |
+
" BASE_MODEL_ID,\n",
|
| 200 |
+
" quantization_config=bnb_config,\n",
|
| 201 |
+
" dtype=torch.bfloat16,\n",
|
| 202 |
+
" device_map=\"auto\",\n",
|
| 203 |
+
" attn_implementation=\"eager\", # recommended for Gemma-family stability; try \"flash_attention_2\" later for speed\n",
|
| 204 |
+
")\n",
|
| 205 |
+
"\n",
|
| 206 |
+
"processor = AutoProcessor.from_pretrained(PROCESSOR_ID)\n",
|
| 207 |
+
"print(\"Model and processor loaded.\")"
|
| 208 |
+
]
|
| 209 |
+
},
|
| 210 |
+
{
|
| 211 |
+
"cell_type": "markdown",
|
| 212 |
+
"id": "40028d2f",
|
| 213 |
+
"metadata": {},
|
| 214 |
+
"source": [
|
| 215 |
+
"## 7. The vision collate function\n",
|
| 216 |
+
"\n",
|
| 217 |
+
"This is the one piece you should **copy verbatim from the [official guide](https://ai.google.dev/gemma/docs/core/huggingface_vision_finetune_qlora)** rather than trust from memory — the exact image-token IDs used for masking are Gemma-version-specific, and getting them wrong silently corrupts training.\n",
|
| 218 |
+
"\n",
|
| 219 |
+
"What it does: turns a batch of chat-format examples (text + PIL images) into model tensors, then builds `labels` and **masks out** the pad tokens and image-placeholder tokens so the model is only graded on generating the graph text, never on \"predicting\" image pixels or padding."
|
| 220 |
+
]
|
| 221 |
+
},
|
| 222 |
+
{
|
| 223 |
+
"cell_type": "code",
|
| 224 |
+
"execution_count": null,
|
| 225 |
+
"id": "d95d1af6",
|
| 226 |
+
"metadata": {},
|
| 227 |
+
"outputs": [],
|
| 228 |
+
"source": [
|
| 229 |
+
"from PIL import Image\n",
|
| 230 |
+
"\n",
|
| 231 |
+
"def process_vision_info(messages):\n",
|
| 232 |
+
" \"\"\"Pull the PIL image(s) out of a chat-format message list.\"\"\"\n",
|
| 233 |
+
" images = []\n",
|
| 234 |
+
" for msg in messages:\n",
|
| 235 |
+
" content = msg.get(\"content\")\n",
|
| 236 |
+
" if isinstance(content, list):\n",
|
| 237 |
+
" for part in content:\n",
|
| 238 |
+
" if part.get(\"type\") == \"image\":\n",
|
| 239 |
+
" img = part[\"image\"]\n",
|
| 240 |
+
" if not isinstance(img, Image.Image):\n",
|
| 241 |
+
" img = Image.open(img)\n",
|
| 242 |
+
" images.append(img.convert(\"RGB\"))\n",
|
| 243 |
+
" return images\n",
|
| 244 |
+
"\n",
|
| 245 |
+
"\n",
|
| 246 |
+
"def collate_fn(examples):\n",
|
| 247 |
+
" texts, images = [], []\n",
|
| 248 |
+
" for ex in examples:\n",
|
| 249 |
+
" texts.append(\n",
|
| 250 |
+
" processor.apply_chat_template(\n",
|
| 251 |
+
" ex[\"messages\"], add_generation_prompt=False, tokenize=False\n",
|
| 252 |
+
" ).strip()\n",
|
| 253 |
+
" )\n",
|
| 254 |
+
" images.append(process_vision_info(ex[\"messages\"]))\n",
|
| 255 |
+
"\n",
|
| 256 |
+
" batch = processor(text=texts, images=images, return_tensors=\"pt\", padding=True)\n",
|
| 257 |
+
"\n",
|
| 258 |
+
" labels = batch[\"input_ids\"].clone()\n",
|
| 259 |
+
" labels[labels == processor.tokenizer.pad_token_id] = -100\n",
|
| 260 |
+
"\n",
|
| 261 |
+
" # --- <<VERIFY AGAINST THE GUIDE>> ---------------------------------------\n",
|
| 262 |
+
" # Mask the image placeholder tokens so they don't contribute to the loss.\n",
|
| 263 |
+
" # The exact special token differs by Gemma version; resolve it from the\n",
|
| 264 |
+
" # processor rather than hardcoding an ID. If training loss looks wrong,\n",
|
| 265 |
+
" # this block is the first place to check against the official guide.\n",
|
| 266 |
+
" special = getattr(processor.tokenizer, \"special_tokens_map\", {})\n",
|
| 267 |
+
" boi = special.get(\"boi_token\")\n",
|
| 268 |
+
" if boi is not None:\n",
|
| 269 |
+
" image_token_id = processor.tokenizer.convert_tokens_to_ids(boi)\n",
|
| 270 |
+
" labels[labels == image_token_id] = -100\n",
|
| 271 |
+
" # ------------------------------------------------------------------------\n",
|
| 272 |
+
"\n",
|
| 273 |
+
" batch[\"labels\"] = labels\n",
|
| 274 |
+
" return batch\n",
|
| 275 |
+
"\n",
|
| 276 |
+
"\n",
|
| 277 |
+
"# Sanity check on one batch\n",
|
| 278 |
+
"_batch = collate_fn([train_ds[0]])\n",
|
| 279 |
+
"print(\"Batch keys:\", list(_batch.keys()))\n",
|
| 280 |
+
"print(\"input_ids shape:\", _batch[\"input_ids\"].shape)"
|
| 281 |
+
]
|
| 282 |
+
},
|
| 283 |
+
{
|
| 284 |
+
"cell_type": "markdown",
|
| 285 |
+
"id": "806ab79f",
|
| 286 |
+
"metadata": {},
|
| 287 |
+
"source": [
|
| 288 |
+
"## 8. LoRA configuration\n",
|
| 289 |
+
"\n",
|
| 290 |
+
"Defines the small trainable adapter. Straight from the guide: rank 16, attached to every linear layer, plus the embedding/head layers (`modules_to_save`) which Gemma needs for its special tokens."
|
| 291 |
+
]
|
| 292 |
+
},
|
| 293 |
+
{
|
| 294 |
+
"cell_type": "code",
|
| 295 |
+
"execution_count": null,
|
| 296 |
+
"id": "195178d8",
|
| 297 |
+
"metadata": {},
|
| 298 |
+
"outputs": [],
|
| 299 |
+
"source": [
|
| 300 |
+
"from peft import LoraConfig\n",
|
| 301 |
+
"\n",
|
| 302 |
+
"peft_config = LoraConfig(\n",
|
| 303 |
+
" r=16,\n",
|
| 304 |
+
" lora_alpha=16,\n",
|
| 305 |
+
" lora_dropout=0.05,\n",
|
| 306 |
+
" bias=\"none\",\n",
|
| 307 |
+
" target_modules=\"all-linear\",\n",
|
| 308 |
+
" task_type=\"CAUSAL_LM\",\n",
|
| 309 |
+
" modules_to_save=[\"lm_head\", \"embed_tokens\"],\n",
|
| 310 |
+
" ensure_weight_tying=True,\n",
|
| 311 |
+
")\n",
|
| 312 |
+
"print(\"LoRA config ready.\")"
|
| 313 |
+
]
|
| 314 |
+
},
|
| 315 |
+
{
|
| 316 |
+
"cell_type": "markdown",
|
| 317 |
+
"id": "65324603",
|
| 318 |
+
"metadata": {},
|
| 319 |
+
"source": [
|
| 320 |
+
"## 9. Training arguments\n",
|
| 321 |
+
"\n",
|
| 322 |
+
"The training recipe (`SFTConfig`). Defaults from the QLoRA paper / Gemma guide. `push_to_hub=True` auto-uploads the adapter to `OUTPUT_REPO`. `report_to=\"tensorboard\"` gives you the loss curves for the next step."
|
| 323 |
+
]
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"cell_type": "code",
|
| 327 |
+
"execution_count": null,
|
| 328 |
+
"id": "6800710e",
|
| 329 |
+
"metadata": {},
|
| 330 |
+
"outputs": [],
|
| 331 |
+
"source": [
|
| 332 |
+
"from trl import SFTConfig\n",
|
| 333 |
+
"\n",
|
| 334 |
+
"training_args = SFTConfig(\n",
|
| 335 |
+
" output_dir=OUTPUT_REPO.split(\"/\")[-1],\n",
|
| 336 |
+
" num_train_epochs=1 if SMOKE_TEST else 3,\n",
|
| 337 |
+
" per_device_train_batch_size=1,\n",
|
| 338 |
+
" gradient_accumulation_steps=4,\n",
|
| 339 |
+
" learning_rate=2e-4,\n",
|
| 340 |
+
" max_grad_norm=0.3,\n",
|
| 341 |
+
" lr_scheduler_type=\"constant\",\n",
|
| 342 |
+
" warmup_ratio=0.03,\n",
|
| 343 |
+
" bf16=True,\n",
|
| 344 |
+
" logging_steps=5,\n",
|
| 345 |
+
" save_strategy=\"epoch\",\n",
|
| 346 |
+
" eval_strategy=\"epoch\",\n",
|
| 347 |
+
" push_to_hub=not SMOKE_TEST, # don't pollute the Hub with smoke-test runs\n",
|
| 348 |
+
" hub_model_id=OUTPUT_REPO,\n",
|
| 349 |
+
" report_to=\"tensorboard\",\n",
|
| 350 |
+
" gradient_checkpointing=True,\n",
|
| 351 |
+
" gradient_checkpointing_kwargs={\"use_reentrant\": False},\n",
|
| 352 |
+
" dataset_kwargs={\"skip_prepare_dataset\": True},\n",
|
| 353 |
+
" remove_unused_columns=False,\n",
|
| 354 |
+
")\n",
|
| 355 |
+
"print(\"Training args ready. push_to_hub =\", training_args.push_to_hub)"
|
| 356 |
+
]
|
| 357 |
+
},
|
| 358 |
+
{
|
| 359 |
+
"cell_type": "markdown",
|
| 360 |
+
"id": "b22d808f",
|
| 361 |
+
"metadata": {},
|
| 362 |
+
"source": [
|
| 363 |
+
"## 10. Train\n",
|
| 364 |
+
"\n",
|
| 365 |
+
"Wires everything into `SFTTrainer` and runs the loop. On a smoke test this is a few minutes; a full run on the real dataset is 1–2 hours on an L4.\n",
|
| 366 |
+
"\n",
|
| 367 |
+
"While it runs, launch TensorBoard (next cell) to watch **eval loss** — it should trend down. If it doesn't, the usual culprit is the dataset format (guidebook step 2.5) or the image-token masking in the collate function, **not** the learning rate."
|
| 368 |
+
]
|
| 369 |
+
},
|
| 370 |
+
{
|
| 371 |
+
"cell_type": "code",
|
| 372 |
+
"execution_count": null,
|
| 373 |
+
"id": "5a42432c",
|
| 374 |
+
"metadata": {},
|
| 375 |
+
"outputs": [],
|
| 376 |
+
"source": [
|
| 377 |
+
"from trl import SFTTrainer\n",
|
| 378 |
+
"\n",
|
| 379 |
+
"trainer = SFTTrainer(\n",
|
| 380 |
+
" model=model,\n",
|
| 381 |
+
" args=training_args,\n",
|
| 382 |
+
" train_dataset=train_ds,\n",
|
| 383 |
+
" eval_dataset=val_ds,\n",
|
| 384 |
+
" peft_config=peft_config,\n",
|
| 385 |
+
" processing_class=processor,\n",
|
| 386 |
+
" data_collator=collate_fn,\n",
|
| 387 |
+
")\n",
|
| 388 |
+
"\n",
|
| 389 |
+
"trainer.train()"
|
| 390 |
+
]
|
| 391 |
+
},
|
| 392 |
+
{
|
| 393 |
+
"cell_type": "code",
|
| 394 |
+
"execution_count": null,
|
| 395 |
+
"id": "1ec4c350",
|
| 396 |
+
"metadata": {},
|
| 397 |
+
"outputs": [],
|
| 398 |
+
"source": [
|
| 399 |
+
"# Optional: view loss curves (run in a separate cell during/after training)\n",
|
| 400 |
+
"%load_ext tensorboard\n",
|
| 401 |
+
"%tensorboard --logdir {training_args.output_dir}"
|
| 402 |
+
]
|
| 403 |
+
},
|
| 404 |
+
{
|
| 405 |
+
"cell_type": "markdown",
|
| 406 |
+
"id": "3dcdaa9f",
|
| 407 |
+
"metadata": {},
|
| 408 |
+
"source": [
|
| 409 |
+
"## 11. Save & push the adapter\n",
|
| 410 |
+
"\n",
|
| 411 |
+
"Saves the trained LoRA adapter. For a real (non-smoke) run with `push_to_hub=True`, this also uploads it to `OUTPUT_REPO` on the Hub."
|
| 412 |
+
]
|
| 413 |
+
},
|
| 414 |
+
{
|
| 415 |
+
"cell_type": "code",
|
| 416 |
+
"execution_count": null,
|
| 417 |
+
"id": "6132caa7",
|
| 418 |
+
"metadata": {},
|
| 419 |
+
"outputs": [],
|
| 420 |
+
"source": [
|
| 421 |
+
"trainer.save_model()\n",
|
| 422 |
+
"\n",
|
| 423 |
+
"if training_args.push_to_hub:\n",
|
| 424 |
+
" trainer.push_to_hub()\n",
|
| 425 |
+
" print(f\"Adapter pushed to https://huggingface.co/{OUTPUT_REPO}\")\n",
|
| 426 |
+
"else:\n",
|
| 427 |
+
" print(f\"Adapter saved locally to ./{training_args.output_dir} (smoke test — not pushed).\")"
|
| 428 |
+
]
|
| 429 |
+
},
|
| 430 |
+
{
|
| 431 |
+
"cell_type": "markdown",
|
| 432 |
+
"id": "308bc33d",
|
| 433 |
+
"metadata": {},
|
| 434 |
+
"source": [
|
| 435 |
+
"## 12. Quick inference test\n",
|
| 436 |
+
"\n",
|
| 437 |
+
"Loads the fine-tuned adapter and runs it on one image so you can eyeball the output before committing to a full evaluation. **This is a smell test, not evaluation** — real numbers come from the eval harness (Phase 3), not from staring at one output."
|
| 438 |
+
]
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"cell_type": "code",
|
| 442 |
+
"execution_count": null,
|
| 443 |
+
"id": "d2eb3a7c",
|
| 444 |
+
"metadata": {},
|
| 445 |
+
"outputs": [],
|
| 446 |
+
"source": [
|
| 447 |
+
"from PIL import Image\n",
|
| 448 |
+
"\n",
|
| 449 |
+
"# Reload base + adapter for a clean-ish inference path\n",
|
| 450 |
+
"model.eval()\n",
|
| 451 |
+
"\n",
|
| 452 |
+
"# <<REPLACE>> with a path to a real test image, or pull one from the test split\n",
|
| 453 |
+
"test_image = Image.open(\"<<PATH_TO_A_TEST_IMAGE.jpg>>\").convert(\"RGB\")\n",
|
| 454 |
+
"\n",
|
| 455 |
+
"messages = [\n",
|
| 456 |
+
" {\"role\": \"system\", \"content\": SYSTEM_PROMPT},\n",
|
| 457 |
+
" {\"role\": \"user\", \"content\": [\n",
|
| 458 |
+
" {\"type\": \"image\", \"image\": test_image},\n",
|
| 459 |
+
" {\"type\": \"text\", \"text\": USER_INSTRUCTION},\n",
|
| 460 |
+
" ]},\n",
|
| 461 |
+
"]\n",
|
| 462 |
+
"\n",
|
| 463 |
+
"inputs = processor.apply_chat_template(\n",
|
| 464 |
+
" messages, add_generation_prompt=True, tokenize=True,\n",
|
| 465 |
+
" return_dict=True, return_tensors=\"pt\",\n",
|
| 466 |
+
").to(model.device)\n",
|
| 467 |
+
"\n",
|
| 468 |
+
"import torch\n",
|
| 469 |
+
"with torch.no_grad():\n",
|
| 470 |
+
" out = model.generate(**inputs, max_new_tokens=2048, do_sample=False)\n",
|
| 471 |
+
"\n",
|
| 472 |
+
"generated = processor.decode(out[0][inputs[\"input_ids\"].shape[-1]:], skip_special_tokens=True)\n",
|
| 473 |
+
"print(generated)\n",
|
| 474 |
+
"\n",
|
| 475 |
+
"# Validate against the real schema (guidebook: reuse validate_extraction from the repo)\n",
|
| 476 |
+
"# from opengraph_image.schema import validate_extraction\n",
|
| 477 |
+
"# errors = validate_extraction(generated)\n",
|
| 478 |
+
"# print(\"Schema-valid!\" if not errors else f\"Schema errors: {errors}\")"
|
| 479 |
+
]
|
| 480 |
+
},
|
| 481 |
+
{
|
| 482 |
+
"cell_type": "markdown",
|
| 483 |
+
"id": "afe564a8",
|
| 484 |
+
"metadata": {},
|
| 485 |
+
"source": [
|
| 486 |
+
"## 13. (Optional) Merge adapter into base weights\n",
|
| 487 |
+
"\n",
|
| 488 |
+
"Only needed if you want a single standalone model for serving with vLLM (guidebook step 4.4). Needs a lot of CPU RAM; skip it if you're serving the adapter directly. On Colab this may require a high-RAM runtime."
|
| 489 |
+
]
|
| 490 |
+
},
|
| 491 |
+
{
|
| 492 |
+
"cell_type": "code",
|
| 493 |
+
"execution_count": null,
|
| 494 |
+
"id": "2f38ae59",
|
| 495 |
+
"metadata": {},
|
| 496 |
+
"outputs": [],
|
| 497 |
+
"source": [
|
| 498 |
+
"# from peft import PeftModel\n",
|
| 499 |
+
"# from transformers import AutoModelForImageTextToText\n",
|
| 500 |
+
"#\n",
|
| 501 |
+
"# base = AutoModelForImageTextToText.from_pretrained(BASE_MODEL_ID, low_cpu_mem_usage=True)\n",
|
| 502 |
+
"# merged = PeftModel.from_pretrained(base, training_args.output_dir).merge_and_unload()\n",
|
| 503 |
+
"# merged.save_pretrained(\"merged_model\", safe_serialization=True, max_shard_size=\"2GB\")\n",
|
| 504 |
+
"# processor.save_pretrained(\"merged_model\")\n",
|
| 505 |
+
"# print(\"Merged model saved to ./merged_model\")"
|
| 506 |
+
]
|
| 507 |
+
},
|
| 508 |
+
{
|
| 509 |
+
"cell_type": "markdown",
|
| 510 |
+
"id": "eec6bae1",
|
| 511 |
+
"metadata": {},
|
| 512 |
+
"source": [
|
| 513 |
+
"## Next steps\n",
|
| 514 |
+
"\n",
|
| 515 |
+
"1. **Turn off `SMOKE_TEST`** and run on the full dataset once the plumbing works end to end.\n",
|
| 516 |
+
"2. **Run the eval harness** (guidebook Phase 3) on the fine-tuned model against the held-out `test` split — that produces the real numbers for the model card, not this one-image smell test.\n",
|
| 517 |
+
"3. **Fill the model card** (`README.md`) with those measured numbers.\n",
|
| 518 |
+
"\n",
|
| 519 |
+
"Reminder from the success-metrics discussion: the honest quality gate is *matching the incumbent pipeline* on the same held-out set, with F1 measured under a **fixed matching function**, node and edge F1 reported **separately**, and every metric **sliced by data source**. Don't publish a headline number off this notebook — publish it off the harness."
|
| 520 |
+
]
|
| 521 |
+
}
|
| 522 |
+
],
|
| 523 |
+
"metadata": {
|
| 524 |
+
"accelerator": "GPU",
|
| 525 |
+
"colab": {
|
| 526 |
+
"gpuType": "L4",
|
| 527 |
+
"provenance": []
|
| 528 |
+
},
|
| 529 |
+
"kernelspec": {
|
| 530 |
+
"display_name": "Python 3",
|
| 531 |
+
"name": "python3"
|
| 532 |
+
},
|
| 533 |
+
"language_info": {
|
| 534 |
+
"name": "python"
|
| 535 |
+
}
|
| 536 |
+
},
|
| 537 |
+
"nbformat": 4,
|
| 538 |
+
"nbformat_minor": 5
|
| 539 |
+
}
|