jk200201 commited on
Commit
f2a3a7b
·
0 Parent(s):

Deploy bf16 LocalSQL Space for ZeroGPU

Browse files
README.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: LocalSQL
3
+ sdk: gradio
4
+ app_file: app.py
5
+ license: apache-2.0
6
+ colorFrom: blue
7
+ colorTo: green
8
+ pinned: false
9
+ ---
10
+
11
+ # LocalSQL
12
+
13
+ Ask a SQLite database questions in plain English.
14
+
15
+ LocalSQL is a small, open text-to-SQL tool built around fine-tuned
16
+ Qwen2.5-Coder-7B LoRA adapters. The default adapter reached **78.2% result
17
+ accuracy on Spider V1 dev**, beating the Grok-4 and DeepSeek-V3 baselines used
18
+ to build its preference data.
19
+
20
+ The product goal is simple:
21
+
22
+ - **Local mode:** run the model on your own GPU, point it at your own `.sqlite`
23
+ file, and keep your data on your machine.
24
+ - **Demo mode:** run the same model in a hosted Gradio app against sample
25
+ data so people can try it without setup.
26
+
27
+ ```bash
28
+ python3 -m src.text2sql --db chinook.sqlite --q "Which 5 artists have the most albums?"
29
+ ```
30
+
31
+ ```text
32
+ SQL
33
+ SELECT ar.Name, COUNT(al.AlbumId) AS album_count
34
+ FROM Artist ar
35
+ JOIN Album al ON ar.ArtistId = al.ArtistId
36
+ GROUP BY ar.ArtistId
37
+ ORDER BY album_count DESC
38
+ LIMIT 5;
39
+
40
+ Results
41
+ | Name | album_count |
42
+ |--------------|-------------|
43
+ | Iron Maiden | 21 |
44
+ | Led Zeppelin | 14 |
45
+ ```
46
+
47
+ ## Why This Exists
48
+
49
+ Most text-to-SQL tools are cloud wrappers: send schema plus question to a large
50
+ API model, pay per request, and trust the remote provider with your database
51
+ structure. LocalSQL explores the opposite path: a specialized 7B model that can
52
+ run locally and still compete with much larger frontier models on SQL tasks.
53
+
54
+ ## Models
55
+
56
+ | Adapter | Best For | Notes |
57
+ |---|---|---|
58
+ | `jk200201/qwen2.5-coder-7b-sql-dpo` | Spider-style/general SQLite questions | Default. 78.2% on Spider V1 dev. |
59
+ | `jk200201/qwen2.5-coder-7b-bird-dpo` | Messier schemas plus domain hints | Better for BIRD-style questions with `--evidence`. |
60
+
61
+ Both adapters sit on top of `Qwen/Qwen2.5-Coder-7B-Instruct`.
62
+
63
+ ## Install
64
+
65
+ ```bash
66
+ git clone https://github.com/jenishk20/finetuning-text-to-sql
67
+ cd finetuning-text-to-sql
68
+ pip install -r requirements.txt
69
+ ```
70
+
71
+ You need a CUDA GPU for 4-bit inference. A hosted demo should use a GPU-backed
72
+ Hugging Face Space or an equivalent GPU service.
73
+
74
+ ## CLI Usage
75
+
76
+ ```bash
77
+ # Generate SQL and execute it read-only against a SQLite database.
78
+ python3 -m src.text2sql \
79
+ --db mydata.sqlite \
80
+ --q "top 5 customers by revenue"
81
+
82
+ # Generate SQL only.
83
+ python3 -m src.text2sql \
84
+ --db mydata.sqlite \
85
+ --q "total revenue per month in 2023" \
86
+ --no-exec
87
+
88
+ # Use the BIRD adapter with a domain hint.
89
+ python3 -m src.text2sql \
90
+ --db mydata.sqlite \
91
+ --adapter jk200201/qwen2.5-coder-7b-bird-dpo \
92
+ --evidence "revenue = price * quantity" \
93
+ --q "revenue by month"
94
+ ```
95
+
96
+ As a library:
97
+
98
+ ```python
99
+ from src.text2sql import load_model, predict
100
+
101
+ model, tokenizer = load_model()
102
+ result = predict("mydata.sqlite", "top 5 customers by revenue", model, tokenizer)
103
+ print(result["sql"])
104
+ print(result["rows"])
105
+ ```
106
+
107
+ ## Hosted Demo
108
+
109
+ This repo includes a minimal Gradio app:
110
+
111
+ ```bash
112
+ python3 app.py
113
+ ```
114
+
115
+ The public Space runs against a generated sample SQLite database. For private
116
+ databases, run the CLI locally so your schema and rows stay on your machine.
117
+
118
+ ## Research Snapshot
119
+
120
+ The training recipe is the interesting part:
121
+
122
+ 1. Run two frontier models on a SQL benchmark.
123
+ 2. Execute their SQL against the benchmark database.
124
+ 3. Where one model is right and the other wrong, turn that disagreement into a
125
+ DPO preference pair.
126
+ 4. SFT a 7B model on gold SQL, then DPO on the generated preference pairs.
127
+
128
+ Spider V1 dev:
129
+
130
+ | Model | Result Accuracy |
131
+ |---|---:|
132
+ | LocalSQL Spider adapter | **78.2%** |
133
+ | Grok-4 baseline | 73.7% |
134
+ | DeepSeek-V3 baseline | 71.8% |
135
+
136
+ For the broader experiment log, see `docs/research/`.
137
+
138
+ ## Roadmap
139
+
140
+ - Hosted Hugging Face Space with sample SQLite databases.
141
+ - High-accuracy mode using Best-of-N execution self-consistency.
142
+ - Schema search/linking for large databases.
143
+ - Postgres and MySQL connectors.
144
+ - Publish a clean BIRD CoT-SFT adapter if the 58.5% result is fully packaged and
145
+ reproducible.
146
+
147
+ ## Citation
148
+
149
+ ```bibtex
150
+ @misc{kothari2026localsql,
151
+ author = {Kothari, Jenish},
152
+ title = {LocalSQL: A Local Text-to-SQL Model Trained with Frontier-Disagreement DPO},
153
+ year = {2026},
154
+ howpublished = {\url{https://huggingface.co/jk200201/qwen2.5-coder-7b-sql-dpo}},
155
+ }
156
+ ```
app.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Minimal Gradio demo for LocalSQL.
3
+
4
+ Deploy this on a GPU-backed Space. The default sample database is generated at
5
+ startup so the repo does not need to track a binary SQLite file.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import sqlite3
10
+ import traceback
11
+ import tempfile
12
+ from functools import lru_cache
13
+ from pathlib import Path
14
+
15
+ import gradio as gr
16
+ import pandas as pd
17
+
18
+ from src.text2sql import DEFAULT_ADAPTER, DEFAULT_BASE, load_model, predict
19
+
20
+ try:
21
+ import spaces
22
+ except ImportError:
23
+ class _SpacesShim:
24
+ @staticmethod
25
+ def GPU(*args, **kwargs):
26
+ def decorator(fn):
27
+ return fn
28
+ return decorator
29
+
30
+ spaces = _SpacesShim()
31
+
32
+ SAMPLE_ROOT = Path(tempfile.gettempdir()) / "localsql_samples"
33
+ SAMPLE_DB = SAMPLE_ROOT / "music_store.sqlite"
34
+
35
+
36
+ def ensure_sample_db() -> Path:
37
+ SAMPLE_ROOT.mkdir(parents=True, exist_ok=True)
38
+ if SAMPLE_DB.exists():
39
+ return SAMPLE_DB
40
+
41
+ conn = sqlite3.connect(SAMPLE_DB)
42
+ cur = conn.cursor()
43
+ cur.executescript(
44
+ """
45
+ CREATE TABLE Artist (
46
+ ArtistId INTEGER PRIMARY KEY,
47
+ Name TEXT NOT NULL
48
+ );
49
+ CREATE TABLE Album (
50
+ AlbumId INTEGER PRIMARY KEY,
51
+ Title TEXT NOT NULL,
52
+ ArtistId INTEGER NOT NULL,
53
+ FOREIGN KEY (ArtistId) REFERENCES Artist(ArtistId)
54
+ );
55
+ CREATE TABLE Track (
56
+ TrackId INTEGER PRIMARY KEY,
57
+ Name TEXT NOT NULL,
58
+ AlbumId INTEGER NOT NULL,
59
+ Milliseconds INTEGER,
60
+ UnitPrice REAL NOT NULL,
61
+ FOREIGN KEY (AlbumId) REFERENCES Album(AlbumId)
62
+ );
63
+ CREATE TABLE Customer (
64
+ CustomerId INTEGER PRIMARY KEY,
65
+ FirstName TEXT NOT NULL,
66
+ LastName TEXT NOT NULL,
67
+ Country TEXT NOT NULL
68
+ );
69
+ CREATE TABLE Invoice (
70
+ InvoiceId INTEGER PRIMARY KEY,
71
+ CustomerId INTEGER NOT NULL,
72
+ InvoiceDate TEXT NOT NULL,
73
+ Total REAL NOT NULL,
74
+ FOREIGN KEY (CustomerId) REFERENCES Customer(CustomerId)
75
+ );
76
+
77
+ INSERT INTO Artist VALUES
78
+ (1, 'Iron Maiden'),
79
+ (2, 'Led Zeppelin'),
80
+ (3, 'Miles Davis'),
81
+ (4, 'Nina Simone');
82
+ INSERT INTO Album VALUES
83
+ (1, 'Powerslave', 1),
84
+ (2, 'Seventh Son of a Seventh Son', 1),
85
+ (3, 'Led Zeppelin IV', 2),
86
+ (4, 'Kind of Blue', 3),
87
+ (5, 'I Put a Spell on You', 4);
88
+ INSERT INTO Track VALUES
89
+ (1, 'Aces High', 1, 271000, 0.99),
90
+ (2, '2 Minutes to Midnight', 1, 360000, 0.99),
91
+ (3, 'Stairway to Heaven', 3, 482000, 0.99),
92
+ (4, 'So What', 4, 545000, 1.29),
93
+ (5, 'Feeling Good', 5, 178000, 1.29);
94
+ INSERT INTO Customer VALUES
95
+ (1, 'Ada', 'Lovelace', 'United Kingdom'),
96
+ (2, 'Grace', 'Hopper', 'United States'),
97
+ (3, 'Katherine', 'Johnson', 'United States');
98
+ INSERT INTO Invoice VALUES
99
+ (1, 1, '2024-01-15', 12.87),
100
+ (2, 2, '2024-02-20', 22.74),
101
+ (3, 2, '2024-03-12', 8.91),
102
+ (4, 3, '2024-03-30', 14.85);
103
+ """
104
+ )
105
+ conn.commit()
106
+ conn.close()
107
+ return SAMPLE_DB
108
+
109
+
110
+ @lru_cache(maxsize=1)
111
+ def get_model():
112
+ print("Loading LocalSQL model in bf16 for ZeroGPU...", flush=True)
113
+ return load_model(DEFAULT_BASE, DEFAULT_ADAPTER, use_4bit=False)
114
+
115
+
116
+ @spaces.GPU(duration=600)
117
+ def answer(
118
+ question: str,
119
+ evidence: str,
120
+ run_sql: bool,
121
+ ):
122
+ if not question.strip():
123
+ return "", pd.DataFrame(), "Ask a question first.", ""
124
+
125
+ db_path = ensure_sample_db()
126
+ try:
127
+ model, tokenizer = get_model()
128
+ result = predict(
129
+ str(db_path),
130
+ question.strip(),
131
+ model,
132
+ tokenizer,
133
+ evidence=evidence.strip(),
134
+ execute=run_sql,
135
+ )
136
+ except Exception as exc:
137
+ traceback.print_exc()
138
+ message = f"{type(exc).__name__}: {exc!s}" if str(exc) else repr(exc)
139
+ return "", pd.DataFrame(), f"Model error: {message}", ""
140
+
141
+ frame = pd.DataFrame(result["rows"], columns=result["columns"]) if result["rows"] else pd.DataFrame()
142
+ status = result["error"] or f"{result['row_count']} rows"
143
+ return result["sql"], frame, status, result["schema"]
144
+
145
+
146
+ with gr.Blocks(title="LocalSQL") as demo:
147
+ gr.Markdown(
148
+ "# LocalSQL\n"
149
+ "Ask a sample SQLite database questions in plain English. "
150
+ "This hosted demo uses sample data; run LocalSQL locally for private databases."
151
+ )
152
+ gr.Markdown(
153
+ "On ZeroGPU, the GPU is allocated only while a query is running. "
154
+ "The first query can take longer while the model downloads and loads."
155
+ )
156
+
157
+ question = gr.Textbox(
158
+ label="Question",
159
+ value="Which artists have the most albums?",
160
+ lines=2,
161
+ )
162
+
163
+ evidence = gr.Textbox(
164
+ label="Optional domain hint",
165
+ placeholder="Example: revenue = unit price * quantity",
166
+ lines=2,
167
+ )
168
+ run_sql = gr.Checkbox(label="Execute generated SQL", value=True)
169
+ submit = gr.Button("Ask LocalSQL", variant="primary")
170
+
171
+ sql = gr.Code(label="Generated SQL", language="sql")
172
+ rows = gr.Dataframe(label="Results", interactive=False)
173
+ status = gr.Textbox(label="Status")
174
+ schema = gr.Code(label="Schema", language="sql")
175
+
176
+ submit.click(
177
+ answer,
178
+ inputs=[question, evidence, run_sql],
179
+ outputs=[sql, rows, status, schema],
180
+ )
181
+
182
+ gr.Examples(
183
+ examples=[
184
+ ["Which artists have the most albums?", "", True],
185
+ ["List customers by total invoice amount.", "", True],
186
+ ["What is the longest track?", "", True],
187
+ ],
188
+ inputs=[question, evidence, run_sql],
189
+ )
190
+
191
+
192
+ if __name__ == "__main__":
193
+ ensure_sample_db()
194
+ demo.launch()
docs/research/README.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Research Notes
2
+
3
+ This repository started as a text-to-SQL fine-tuning research project and is now
4
+ being shaped into LocalSQL, a usable local/private SQL assistant.
5
+
6
+ ## Core Result
7
+
8
+ The first strong result was Spider V1:
9
+
10
+ - Fine-tuned Qwen2.5-Coder-7B: **78.2%** result accuracy.
11
+ - Grok-4 baseline: 73.7%.
12
+ - DeepSeek-V3 baseline: 71.8%.
13
+
14
+ The method was frontier-disagreement DPO: run two strong models, execute their
15
+ SQL, and turn correctness disagreements into DPO preference pairs.
16
+
17
+ ## BIRD Findings
18
+
19
+ BIRD is harder because schemas are larger, train/dev databases are disjoint, and
20
+ questions include external evidence. The important lessons so far:
21
+
22
+ - Clear correctness pairs helped the 7B model.
23
+ - Judge-resolved "both correct but stylistically different" pairs hurt BIRD,
24
+ because BIRD rewards execution correctness, not SQL style.
25
+ - Reasoning distillation plus execution-based Best-of-N is the strongest story
26
+ to package next, pending final artifact verification.
27
+
28
+ ## Branches
29
+
30
+ - `main`: public product/reproducible baseline branch.
31
+ - `feat/localsql-product`: current cleanup branch for the LocalSQL product.
32
+ - `feat/bird-7b-clean`: GRPO/execution-reward research branch.
33
+ - `feat/excot-onpolicy`: reasoning-distillation and Best-of-N research branch.
model_cards/qwen2.5-coder-7b-bird-dpo.md ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: Qwen/Qwen2.5-Coder-7B-Instruct
4
+ library_name: peft
5
+ tags:
6
+ - text-to-sql
7
+ - sql
8
+ - code-generation
9
+ - bird
10
+ - bird-sql
11
+ - spider
12
+ - dpo
13
+ - qwen
14
+ - qwen2.5
15
+ - text-generation
16
+ language:
17
+ - en
18
+ pipeline_tag: text-generation
19
+ datasets:
20
+ - birdsql/bird_mini_dev
21
+ ---
22
+
23
+ # Qwen2.5-Coder-7B BIRD-DPO
24
+
25
+ A LoRA adapter for **Qwen2.5-Coder-7B-Instruct** fine-tuned for real-world Text-to-SQL with **DPO using frontier model disagreements**. Achieves **50.3% on the BIRD dev set** — a +23.3pp improvement over the base model on real-world database queries with messy schemas and domain-specific evidence.
26
+
27
+ ## Results
28
+
29
+ | Benchmark | Metric | Score |
30
+ |---|---|---|
31
+ | **BIRD dev** (1,534 Q) | Result accuracy | **50.3%** |
32
+ | **BIRD mini-dev** (500 Q) | Result accuracy | **44.4%** |
33
+ | BIRD mini-dev — Simple | Result accuracy | 62.8% |
34
+ | BIRD mini-dev — Moderate | Result accuracy | 38.8% |
35
+ | BIRD mini-dev — Challenging | Result accuracy | 31.4% |
36
+ | **Spider V1 dev** (1,034 Q, cross-eval) | Result accuracy | **75.9%** |
37
+
38
+ **Cross-domain transfer**: trained purely on BIRD, this adapter scores **75.9% on Spider** — only 2.3pp below a Spider-specific adapter (78.2%). Strong evidence that DPO from frontier disagreements generalizes across SQL benchmarks.
39
+
40
+ | Model | BIRD dev |
41
+ |---|---|
42
+ | Qwen2.5-Coder-7B base | 27.0% |
43
+ | Qwen2.5-Coder-7B + SFT only | ~35% |
44
+ | **Qwen2.5-Coder-7B + SFT + BIRD-DPO (this model)** | **50.3%** |
45
+
46
+ ## Quick Start
47
+
48
+ ```python
49
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
50
+ from peft import PeftModel
51
+ import torch
52
+
53
+ BASE_MODEL = "Qwen/Qwen2.5-Coder-7B-Instruct"
54
+ ADAPTER = "jk200201/qwen2.5-coder-7b-bird-dpo"
55
+
56
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
57
+
58
+ bnb = BitsAndBytesConfig(
59
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
60
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
61
+ )
62
+ model = AutoModelForCausalLM.from_pretrained(
63
+ BASE_MODEL, quantization_config=bnb, device_map="auto", trust_remote_code=True
64
+ )
65
+ model = PeftModel.from_pretrained(model, ADAPTER)
66
+ model.eval()
67
+
68
+ schema = "CREATE TABLE users (id INT, name TEXT, country TEXT);"
69
+ question = "How many users are from Japan?"
70
+ evidence = "" # optional domain hint for BIRD-style queries
71
+
72
+ prompt = f"""Convert the following natural language question into a valid SQL query.
73
+
74
+ Database Schema:
75
+ {schema}
76
+
77
+ {f'External Knowledge:{chr(10)}{evidence}{chr(10)}{chr(10)}' if evidence.strip() else ''}Question: {question}
78
+
79
+ Return only the SQL query with no explanation."""
80
+
81
+ inputs = tokenizer.apply_chat_template(
82
+ [{"role": "user", "content": prompt}],
83
+ return_tensors="pt", add_generation_prompt=True
84
+ ).to(model.device)
85
+
86
+ out = model.generate(inputs, max_new_tokens=256, do_sample=False, pad_token_id=tokenizer.eos_token_id)
87
+ sql = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True).strip()
88
+ print(sql)
89
+ ```
90
+
91
+ ## Training Details
92
+
93
+ **The novel idea**: rather than human-annotated preferences, this model uses **automatically generated preference pairs from frontier model disagreements** — total cost: ~$25 of OpenRouter API calls.
94
+
95
+ ### Pipeline
96
+
97
+ 1. Run **Grok-4.1-fast** and **DeepSeek-V3.2** on BIRD train (9,428 questions). Both score ~53%.
98
+ 2. Compare results question-by-question. Where one model is right and the other wrong → preference pair (1,219 pairs from BIRD).
99
+ 3. SFT Qwen2.5-Coder-7B on BIRD train gold SQL (QLoRA r=32, α=64, NF4 4-bit, 3 epochs).
100
+ 4. DPO on the 1,219 clear-preference pairs on top of SFT (β=0.05, 1 epoch, cutoff=8192).
101
+
102
+ ### Hyperparameters
103
+
104
+ | Stage | Setting |
105
+ |---|---|
106
+ | Quantization | 4-bit NF4 (QLoRA) |
107
+ | LoRA rank | 32 |
108
+ | LoRA alpha | 64 |
109
+ | LoRA dropout | 0.05 |
110
+ | Target modules | q/k/v/o_proj, gate/up/down_proj |
111
+ | SFT epochs | 3, LR 2e-4 cosine |
112
+ | DPO epochs | 1, LR 5e-5 cosine, β=0.05 |
113
+ | Cutoff length | 8,192 tokens (H200 80GB) |
114
+
115
+ ### Hardware
116
+
117
+ Northeastern Discovery HPC — single NVIDIA H200 80GB. Training time: ~1h 15min.
118
+
119
+ ## Key Finding — What Doesn't Work
120
+
121
+ Initially trained with 4,677 pairs (1,219 clear-preference + 3,458 judge-resolved style pairs). This **regressed to 40.7%** (-9.6pp).
122
+
123
+ **Lesson**: judge-resolved pairs where both models are correct but write different SQL carry zero correctness signal for BIRD's result-accuracy metric. They dilute training and hurt performance. Only use pairs where one model is right and one is wrong.
124
+
125
+ ## Limitations
126
+
127
+ - BIRD test set was not used (hidden); evaluation is on the public dev set
128
+ - Real-world databases with very long schemas (>8K tokens) get truncated
129
+ - Optimized for SQLite syntax (BIRD format); MySQL/PostgreSQL outputs may need adaptation
130
+ - Trained only on English questions
131
+
132
+ ## Related Models
133
+
134
+ - **Spider version** of this approach: [`jk200201/qwen2.5-coder-7b-sql-dpo`](https://huggingface.co/jk200201/qwen2.5-coder-7b-sql-dpo) — 78.2% on Spider V1, also beats Grok-4 and DeepSeek V3
135
+
136
+ ## Citation
137
+
138
+ If you use this model, please cite:
139
+
140
+ ```bibtex
141
+ @misc{kothari2026qwenbirddpo,
142
+ author = {Kothari, Jenish},
143
+ title = {Qwen2.5-Coder-7B BIRD-DPO: Frontier-Disagreement DPO for Real-World Text-to-SQL},
144
+ year = {2026},
145
+ publisher = {Hugging Face},
146
+ howpublished = {\url{https://huggingface.co/jk200201/qwen2.5-coder-7b-bird-dpo}},
147
+ }
148
+ ```
model_cards/qwen2.5-coder-7b-sql-dpo.md ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: Qwen/Qwen2.5-Coder-7B-Instruct
4
+ library_name: peft
5
+ tags:
6
+ - text-to-sql
7
+ - sql
8
+ - code-generation
9
+ - spider
10
+ - dpo
11
+ - qwen
12
+ - qwen2.5
13
+ - text-generation
14
+ language:
15
+ - en
16
+ pipeline_tag: text-generation
17
+ datasets:
18
+ - jk200201/spider-dpo-1040
19
+ ---
20
+
21
+ # Qwen2.5-Coder-7B Spider-DPO
22
+
23
+ A LoRA adapter for **Qwen2.5-Coder-7B-Instruct** fine-tuned with DPO that achieves **78.2% on Spider V1 dev** — outperforming Grok-4 (73.7%) and DeepSeek V3 (71.8%) despite being a 7B model.
24
+
25
+ ## Results
26
+
27
+ | Model | Spider V1 dev | Parameters |
28
+ |---|---|---|
29
+ | **Qwen2.5-Coder-7B + Spider-DPO (this model)** | **78.2%** | 7B |
30
+ | Grok-4 (frontier baseline) | 73.7% | unknown (very large) |
31
+ | DeepSeek-V3 (frontier baseline) | 71.8% | 671B (37B active MoE) |
32
+ | Qwen2.5-Coder-7B base | ~50% | 7B |
33
+
34
+ ### Cross-benchmark transfer
35
+
36
+ | Benchmark | Score |
37
+ |---|---|
38
+ | Spider V1 dev (in-domain) | **78.2%** |
39
+
40
+ For real-world database queries (BIRD-style schemas with evidence), use the companion model: [`jk200201/qwen2.5-coder-7b-bird-dpo`](https://huggingface.co/jk200201/qwen2.5-coder-7b-bird-dpo).
41
+
42
+ ## Quick Start
43
+
44
+ ```python
45
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
46
+ from peft import PeftModel
47
+ import torch
48
+
49
+ BASE_MODEL = "Qwen/Qwen2.5-Coder-7B-Instruct"
50
+ ADAPTER = "jk200201/qwen2.5-coder-7b-sql-dpo"
51
+
52
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL, trust_remote_code=True)
53
+
54
+ bnb = BitsAndBytesConfig(
55
+ load_in_4bit=True, bnb_4bit_quant_type="nf4",
56
+ bnb_4bit_compute_dtype=torch.bfloat16, bnb_4bit_use_double_quant=True,
57
+ )
58
+ model = AutoModelForCausalLM.from_pretrained(
59
+ BASE_MODEL, quantization_config=bnb, device_map="auto", trust_remote_code=True
60
+ )
61
+ model = PeftModel.from_pretrained(model, ADAPTER)
62
+ model.eval()
63
+
64
+ schema = "CREATE TABLE users (id INT, name TEXT, country TEXT);"
65
+ question = "How many users are from Japan?"
66
+
67
+ prompt = f"""Convert the following natural language question into a valid SQL query.
68
+
69
+ Database Schema:
70
+ {schema}
71
+
72
+ Question: {question}
73
+
74
+ Return only the SQL query with no explanation."""
75
+
76
+ inputs = tokenizer.apply_chat_template(
77
+ [{"role": "user", "content": prompt}],
78
+ return_tensors="pt", add_generation_prompt=True
79
+ ).to(model.device)
80
+
81
+ out = model.generate(inputs, max_new_tokens=256, do_sample=False, pad_token_id=tokenizer.eos_token_id)
82
+ sql = tokenizer.decode(out[0][inputs.shape[-1]:], skip_special_tokens=True).strip()
83
+ print(sql)
84
+ ```
85
+
86
+ ## Training Details
87
+
88
+ **The novel idea**: rather than human-annotated preferences, this model uses **automatically generated preference pairs from frontier model disagreements** — total cost: ~$25 of OpenRouter API calls.
89
+
90
+ ### Pipeline
91
+
92
+ 1. Run **Grok-4** and **DeepSeek-V3** on Spider dev set (1,034 questions).
93
+ 2. Compare against gold SQL question-by-question. Where one frontier model is right and the other wrong → preference pair (the correct SQL is "chosen", the wrong one "rejected").
94
+ 3. SFT Qwen2.5-Coder-7B on Spider train gold SQL (QLoRA r=32, α=64, NF4 4-bit, 3 epochs).
95
+ 4. DPO on **1,040 clear-preference pairs** on top of SFT (β=0.1, 2 epochs).
96
+
97
+ ### Hyperparameters
98
+
99
+ | Stage | Setting |
100
+ |---|---|
101
+ | Quantization | 4-bit NF4 (QLoRA) |
102
+ | LoRA rank | 32 |
103
+ | LoRA alpha | 64 |
104
+ | LoRA dropout | 0.05 |
105
+ | Target modules | q/k/v/o_proj, gate/up/down_proj |
106
+ | SFT epochs | 3, LR 2e-4 cosine |
107
+ | DPO epochs | 2, LR 5e-5 cosine, β=0.1 |
108
+
109
+ ### Training data
110
+
111
+ [`jk200201/spider-dpo-1040`](https://huggingface.co/datasets/jk200201/spider-dpo-1040) — 1,040 preference pairs built from Grok-4 vs DeepSeek-V3 disagreements on Spider dev.
112
+
113
+ ### Hardware
114
+
115
+ AWS EC2 g5.xlarge (NVIDIA A10G 24GB VRAM). Training time: ~3h total.
116
+
117
+ ## Limitations
118
+
119
+ - Designed for **Spider-style** queries: academic-style English, clean schemas, single SQLite dialect
120
+ - For real-world messy databases with domain knowledge ("BIRD-style"), use [`jk200201/qwen2.5-coder-7b-bird-dpo`](https://huggingface.co/jk200201/qwen2.5-coder-7b-bird-dpo)
121
+ - 4-bit quantized — for highest accuracy use bf16 base model
122
+ - Trained only on English questions
123
+
124
+ ## Citation
125
+
126
+ ```bibtex
127
+ @misc{kothari2026qwenspiderdpo,
128
+ author = {Kothari, Jenish},
129
+ title = {Qwen2.5-Coder-7B Spider-DPO: A 7B Model that Beats Frontier Models on Spider via Frontier-Disagreement DPO},
130
+ year = {2026},
131
+ publisher = {Hugging Face},
132
+ howpublished = {\url{https://huggingface.co/jk200201/qwen2.5-coder-7b-sql-dpo}},
133
+ }
134
+ ```
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ pandas>=2.0.0
2
+ tabulate>=0.9.0
3
+ gradio>=4.44.0
4
+ spaces
5
+
6
+ torch>=2.4.0
7
+ transformers>=4.48.0
8
+ peft>=0.14.0
9
+ accelerate>=1.0.0
src/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
src/bird/__init__.py ADDED
File without changes
src/bird/inference.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared inference utilities for BIRD pipelines.
3
+
4
+ Provides:
5
+ - build_instruction(): the canonical prompt format (must match training)
6
+ - BIRDvLLMEngine: vLLM-backed batched generation with optional Best-of-N
7
+ - _flash_attn_available(): helper used by trainer scripts
8
+
9
+ This module is imported by:
10
+ - src.bird.eval_finetuned (build_instruction)
11
+ - src.bird.build_sft_data (build_instruction)
12
+ - src.bird.build_pairs (build_instruction)
13
+ - src.bird.sft_train (_flash_attn_available)
14
+ - src.bird.dpo_train (_flash_attn_available)
15
+ - src.bird.eval_best_of_n (BIRDvLLMEngine)
16
+ - src.bird.build_self_sampling_pairs (BIRDvLLMEngine)
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import re
21
+ from typing import Iterable
22
+
23
+
24
+ # ─────────────────────────────────────────────────────────────────────────────
25
+ # PROMPT — must match exactly what was used during DPO training
26
+ # Single source of truth across the codebase
27
+ # ─────────────────────────────────────────────────────────────────────────────
28
+
29
+ def build_instruction(question: str, schema: str, evidence: str = "") -> str:
30
+ """Canonical BIRD prompt format. Used by training data builders + eval."""
31
+ evidence_block = f"External Knowledge:\n{evidence}\n\n" if evidence.strip() else ""
32
+ return (
33
+ "Convert the following natural language question into a valid SQL query.\n\n"
34
+ f"Database Schema:\n{schema}\n\n"
35
+ f"{evidence_block}"
36
+ f"Question: {question}\n\n"
37
+ "Return only the SQL query with no explanation."
38
+ )
39
+
40
+
41
+ SYSTEM_PROMPT = (
42
+ "You are an expert SQL query generator. "
43
+ "Given a database schema and a natural language question, "
44
+ "generate a single valid SQL query that answers the question. "
45
+ "Output ONLY the SQL query, nothing else."
46
+ )
47
+
48
+
49
+ # ─────────────────────────────────────────────────────────────────────────────
50
+ # OUTPUT POSTPROCESSING — strip markdown fences, add trailing semicolon
51
+ # ─────────────────────────────────────────────────────────────────────────────
52
+
53
+ _FENCE_RE = re.compile(r"```(?:sql)?\s*\n?(.*?)```", re.DOTALL)
54
+
55
+
56
+ def extract_sql(text: str) -> str:
57
+ """Strip markdown fences, return SQL with trailing semicolon."""
58
+ text = text.strip()
59
+ match = _FENCE_RE.search(text)
60
+ if match:
61
+ text = match.group(1).strip()
62
+ return text if text.endswith(";") else text + ";"
63
+
64
+
65
+ # ─────────────────────────────────────────────────────────────────────────────
66
+ # FLASH-ATTENTION HELPER — used by SFT + DPO trainers
67
+ # ─────────────────────────────────────────────────────────────────────────────
68
+
69
+ def _flash_attn_available() -> bool:
70
+ try:
71
+ import flash_attn # noqa: F401
72
+ return True
73
+ except ImportError:
74
+ return False
75
+
76
+
77
+ # ─────────────────────────────────────────────────────────────────────────────
78
+ # vLLM BATCHED INFERENCE ENGINE
79
+ # ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ class BIRDvLLMEngine:
82
+ """
83
+ vLLM-backed batched SQL generation. Supports K-sample generation for
84
+ Best-of-N inference and self-sampling preference pair generation.
85
+
86
+ Loads base model + (optional) LoRA adapter once. All subsequent
87
+ generate() calls reuse the loaded engine.
88
+
89
+ Usage:
90
+ engine = BIRDvLLMEngine(
91
+ base_model="Qwen/Qwen2.5-Coder-14B-Instruct",
92
+ adapter_path="/scratch/.../bird_sft_adapter_14b/checkpoint-3100",
93
+ )
94
+ # Each item is a tuple (question, schema, evidence)
95
+ prompts = [(q, s, e) for q, s, e in items]
96
+ # Returns: list of (list of K candidate SQL strings)
97
+ candidates_per_question = engine.generate(prompts, k=4, temperature=0.8)
98
+ """
99
+
100
+ def __init__(
101
+ self,
102
+ base_model: str,
103
+ adapter_path: str | None = None,
104
+ max_model_len: int = 8192,
105
+ gpu_memory_utilization: float = 0.90,
106
+ dtype: str = "bfloat16",
107
+ ):
108
+ from vllm import LLM
109
+ from vllm.lora.request import LoRARequest
110
+ from transformers import AutoTokenizer
111
+
112
+ self.tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
113
+ if self.tokenizer.pad_token is None:
114
+ self.tokenizer.pad_token = self.tokenizer.eos_token
115
+
116
+ self.llm = LLM(
117
+ model=base_model,
118
+ dtype=dtype,
119
+ max_model_len=max_model_len,
120
+ gpu_memory_utilization=gpu_memory_utilization,
121
+ enable_lora=adapter_path is not None,
122
+ max_lora_rank=64, # safe upper bound; covers r=32 with margin
123
+ trust_remote_code=True,
124
+ )
125
+
126
+ self.lora_request = None
127
+ if adapter_path:
128
+ self.lora_request = LoRARequest(
129
+ lora_name="bird_adapter",
130
+ lora_int_id=1,
131
+ lora_path=adapter_path,
132
+ )
133
+ print(f"vLLM engine loaded with adapter: {adapter_path}")
134
+ else:
135
+ print(f"vLLM engine loaded (base model only, no adapter)")
136
+
137
+ def _build_chat_prompt(self, question: str, schema: str, evidence: str) -> str:
138
+ messages = [
139
+ {"role": "system", "content": SYSTEM_PROMPT},
140
+ {"role": "user", "content": build_instruction(question, schema, evidence)},
141
+ ]
142
+ return self.tokenizer.apply_chat_template(
143
+ messages, tokenize=False, add_generation_prompt=True
144
+ )
145
+
146
+ def generate(
147
+ self,
148
+ items: Iterable[tuple[str, str, str]],
149
+ k: int = 1,
150
+ temperature: float = 0.0,
151
+ top_p: float = 1.0,
152
+ max_tokens: int = 512,
153
+ ) -> list[list[str]]:
154
+ """
155
+ Args:
156
+ items: iterable of (question, schema, evidence) tuples
157
+ k: number of candidates per question (1 = greedy, K>1 = sampling)
158
+ temperature: 0.0 for greedy, 0.7-1.0 for sampling
159
+ max_tokens: max generated tokens per candidate
160
+
161
+ Returns:
162
+ list of length len(items); each element is a list of K SQL strings
163
+ """
164
+ from vllm import SamplingParams
165
+
166
+ items = list(items)
167
+ prompts = [self._build_chat_prompt(q, s, e) for q, s, e in items]
168
+
169
+ sampling_params = SamplingParams(
170
+ n=k,
171
+ temperature=temperature if k > 1 else 0.0,
172
+ top_p=top_p,
173
+ max_tokens=max_tokens,
174
+ )
175
+
176
+ outputs = self.llm.generate(
177
+ prompts,
178
+ sampling_params=sampling_params,
179
+ lora_request=self.lora_request,
180
+ )
181
+
182
+ results: list[list[str]] = []
183
+ for out in outputs:
184
+ cands = [extract_sql(o.text) for o in out.outputs]
185
+ results.append(cands)
186
+ return results
src/shared/__init__.py ADDED
File without changes
src/shared/schema_loader.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Schema loader for Spider databases.
3
+
4
+ Extracts CREATE TABLE DDL from SQLite database files for use in prompts.
5
+ """
6
+
7
+ import sqlite3
8
+ from pathlib import Path
9
+
10
+
11
+ def get_schema_from_sqlite(db_path: str) -> str:
12
+ """
13
+ Extract CREATE TABLE statements directly from a SQLite database file.
14
+ This is the most reliable way since it matches the actual DB structure.
15
+ """
16
+ conn = sqlite3.connect(db_path)
17
+ cursor = conn.cursor()
18
+
19
+ cursor.execute(
20
+ "SELECT sql FROM sqlite_master WHERE type='table' AND sql IS NOT NULL"
21
+ )
22
+ tables = cursor.fetchall()
23
+ conn.close()
24
+
25
+ ddl_statements = []
26
+ for (sql,) in tables:
27
+ if sql and "sqlite_sequence" not in sql.lower():
28
+ ddl_statements.append(sql.strip() + ";")
29
+
30
+ return "\n\n".join(ddl_statements)
31
+
32
+
33
+ def get_db_path(spider_data_dir: str, db_id: str) -> str:
34
+ """Resolve the full path to a Spider SQLite database file."""
35
+ base = Path(spider_data_dir) / "database" / db_id / f"{db_id}.sqlite"
36
+ if base.exists():
37
+ return str(base)
38
+
39
+ # Fallback: search for any .sqlite file in the directory
40
+ db_dir = Path(spider_data_dir) / "database" / db_id
41
+ if db_dir.exists():
42
+ for f in db_dir.glob("*.sqlite"):
43
+ return str(f)
44
+
45
+ raise FileNotFoundError(f"No SQLite database found for db_id={db_id} in {spider_data_dir}")
src/shared/sqlite_executor.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SQL executor for running queries against Spider's SQLite databases.
3
+ """
4
+
5
+ import sqlite3
6
+ import threading
7
+ import time
8
+
9
+
10
+ def execute_sqlite_query(sql: str, db_path: str, timeout: int = 30) -> dict:
11
+ """
12
+ Execute a SQL query against a SQLite database file.
13
+
14
+ Returns a dict with:
15
+ - "success": bool
16
+ - "columns": list of column names
17
+ - "rows": list of tuples
18
+ - "row_count": number of rows
19
+ - "execution_time_ms": time taken
20
+ - "error": error message (if failure)
21
+ """
22
+ conn = None
23
+ try:
24
+ conn = sqlite3.connect(db_path)
25
+ conn.execute("PRAGMA foreign_keys = ON")
26
+ conn.text_factory = str
27
+ cursor = conn.cursor()
28
+
29
+ # Enforce hard timeout via conn.interrupt() — the timeout parameter
30
+ # in sqlite3.connect() only covers lock acquisition, not query execution.
31
+ timer = threading.Timer(timeout, conn.interrupt)
32
+ timer.start()
33
+
34
+ start = time.time()
35
+
36
+ statements = [s.strip() for s in sql.split(";") if s.strip()]
37
+
38
+ columns = []
39
+ rows = []
40
+
41
+ try:
42
+ for stmt in statements:
43
+ cursor.execute(stmt)
44
+ if cursor.description:
45
+ columns = [desc[0] for desc in cursor.description]
46
+ rows = cursor.fetchall()
47
+ finally:
48
+ timer.cancel()
49
+
50
+ elapsed_ms = round((time.time() - start) * 1000, 2)
51
+
52
+ return {
53
+ "success": True,
54
+ "columns": columns,
55
+ "rows": rows,
56
+ "row_count": len(rows),
57
+ "execution_time_ms": elapsed_ms,
58
+ "error": None,
59
+ }
60
+
61
+ except Exception as e:
62
+ return {
63
+ "success": False,
64
+ "columns": [],
65
+ "rows": [],
66
+ "row_count": 0,
67
+ "execution_time_ms": 0,
68
+ "error": str(e),
69
+ }
70
+
71
+ finally:
72
+ if conn:
73
+ conn.close()
src/text2sql.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Local text-to-SQL over SQLite databases.
3
+
4
+ The public product path is:
5
+ - load a Qwen2.5-Coder-7B base model plus a SQL LoRA adapter
6
+ - introspect a SQLite schema
7
+ - generate SQL from a natural-language question
8
+ - optionally execute only read-only SQL against the database
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import re
14
+ import sys
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import torch
19
+ from peft import PeftModel
20
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig
21
+
22
+ from src.bird.inference import build_instruction, extract_sql
23
+ from src.shared.schema_loader import get_schema_from_sqlite
24
+ from src.shared.sqlite_executor import execute_sqlite_query
25
+
26
+ DEFAULT_BASE = "Qwen/Qwen2.5-Coder-7B-Instruct"
27
+ DEFAULT_ADAPTER = "jk200201/qwen2.5-coder-7b-sql-dpo"
28
+
29
+ ADAPTERS = {
30
+ "spider": "jk200201/qwen2.5-coder-7b-sql-dpo",
31
+ "bird": "jk200201/qwen2.5-coder-7b-bird-dpo",
32
+ "base": "",
33
+ }
34
+
35
+
36
+ def resolve_adapter(adapter: str | None) -> str | None:
37
+ """Accept a shortcut, HF repo id, local path, empty string, or None."""
38
+ if adapter is None:
39
+ return DEFAULT_ADAPTER
40
+ if adapter in ADAPTERS:
41
+ return ADAPTERS[adapter] or None
42
+ return adapter or None
43
+
44
+
45
+ def load_model(
46
+ base_model: str = DEFAULT_BASE,
47
+ adapter: str | None = DEFAULT_ADAPTER,
48
+ use_4bit: bool = True,
49
+ ):
50
+ """Load the base model plus an optional LoRA adapter."""
51
+ adapter = resolve_adapter(adapter)
52
+
53
+ if use_4bit and not torch.cuda.is_available():
54
+ raise RuntimeError(
55
+ "4-bit inference needs a CUDA GPU. On Hugging Face Spaces, open "
56
+ "Settings -> Hardware and choose a GPU such as Nvidia L4 before "
57
+ "running the demo."
58
+ )
59
+
60
+ print(
61
+ f"Loading {base_model}" + (f" + {adapter}" if adapter else " (base only)"),
62
+ file=sys.stderr,
63
+ )
64
+ tokenizer = AutoTokenizer.from_pretrained(base_model, trust_remote_code=True)
65
+ if tokenizer.pad_token is None:
66
+ tokenizer.pad_token = tokenizer.eos_token
67
+
68
+ kwargs: dict[str, Any] = {"trust_remote_code": True, "device_map": "auto"}
69
+ if use_4bit:
70
+ kwargs["quantization_config"] = BitsAndBytesConfig(
71
+ load_in_4bit=True,
72
+ bnb_4bit_quant_type="nf4",
73
+ bnb_4bit_compute_dtype=torch.bfloat16,
74
+ bnb_4bit_use_double_quant=True,
75
+ )
76
+ else:
77
+ kwargs["torch_dtype"] = torch.bfloat16
78
+
79
+ model = AutoModelForCausalLM.from_pretrained(base_model, **kwargs)
80
+ if adapter:
81
+ model = PeftModel.from_pretrained(model, adapter)
82
+ model.eval()
83
+ return model, tokenizer
84
+
85
+
86
+ def generate_sql(
87
+ model,
88
+ tokenizer,
89
+ schema: str,
90
+ question: str,
91
+ evidence: str = "",
92
+ max_new_tokens: int = 256,
93
+ ) -> str:
94
+ """Build the training-time prompt, greedily decode, and return SQL."""
95
+ instruction = build_instruction(question, schema, evidence)
96
+ inputs = tokenizer.apply_chat_template(
97
+ [{"role": "user", "content": instruction}],
98
+ return_tensors="pt",
99
+ add_generation_prompt=True,
100
+ ).to(model.device)
101
+
102
+ with torch.no_grad():
103
+ outputs = model.generate(
104
+ inputs,
105
+ max_new_tokens=max_new_tokens,
106
+ do_sample=False,
107
+ pad_token_id=tokenizer.eos_token_id,
108
+ )
109
+ raw = tokenizer.decode(outputs[0][inputs.shape[-1]:], skip_special_tokens=True)
110
+ return extract_sql(raw)
111
+
112
+
113
+ def is_read_only_sql(sql: str) -> bool:
114
+ """
115
+ Conservative guard for product execution.
116
+
117
+ We allow SELECT/WITH queries only and reject obvious mutating statements.
118
+ This keeps the CLI/demo from modifying a user's database by accident.
119
+ """
120
+ cleaned = re.sub(r"--.*?$|/\*.*?\*/", "", sql, flags=re.MULTILINE | re.DOTALL).strip()
121
+ if not cleaned:
122
+ return False
123
+ first_token = cleaned.split(None, 1)[0].lower().rstrip(";")
124
+ if first_token not in {"select", "with"}:
125
+ return False
126
+ blocked = re.search(
127
+ r"\b(insert|update|delete|drop|alter|create|replace|attach|detach|vacuum|pragma)\b",
128
+ cleaned,
129
+ flags=re.IGNORECASE,
130
+ )
131
+ return blocked is None
132
+
133
+
134
+ def predict(
135
+ db_path: str,
136
+ question: str,
137
+ model,
138
+ tokenizer,
139
+ evidence: str = "",
140
+ execute: bool = True,
141
+ max_new_tokens: int = 256,
142
+ ) -> dict:
143
+ """Introspect schema, generate SQL, and optionally run it read-only."""
144
+ schema = get_schema_from_sqlite(db_path)
145
+ sql = generate_sql(model, tokenizer, schema, question, evidence, max_new_tokens)
146
+
147
+ result = {
148
+ "sql": sql,
149
+ "columns": [],
150
+ "rows": [],
151
+ "row_count": 0,
152
+ "error": None,
153
+ "schema": schema,
154
+ }
155
+
156
+ if execute:
157
+ if not is_read_only_sql(sql):
158
+ result["error"] = "Refusing to execute non-read-only SQL. Use --no-exec to inspect it."
159
+ return result
160
+ exec_out = execute_sqlite_query(sql, db_path)
161
+ result.update(
162
+ columns=exec_out["columns"],
163
+ rows=exec_out["rows"],
164
+ row_count=exec_out["row_count"],
165
+ error=exec_out["error"],
166
+ )
167
+ return result
168
+
169
+
170
+ def _print_result(result: dict) -> None:
171
+ print("\nSQL")
172
+ print(result["sql"])
173
+
174
+ if result["error"]:
175
+ print("\nError")
176
+ print(result["error"])
177
+ return
178
+
179
+ print(f"\nResults ({result['row_count']} rows)")
180
+ try:
181
+ from tabulate import tabulate
182
+
183
+ print(tabulate(result["rows"][:50], headers=result["columns"], tablefmt="github"))
184
+ except ImportError:
185
+ print(result["columns"])
186
+ for row in result["rows"][:50]:
187
+ print(row)
188
+ if result["row_count"] > 50:
189
+ print(f"... ({result['row_count'] - 50} more rows)")
190
+
191
+
192
+ def main() -> None:
193
+ parser = argparse.ArgumentParser(description="Local text-to-SQL over a SQLite database")
194
+ parser.add_argument("--db", required=True, help="Path to a .sqlite database file")
195
+ parser.add_argument("--q", "--question", dest="question", required=True)
196
+ parser.add_argument(
197
+ "--adapter",
198
+ default=DEFAULT_ADAPTER,
199
+ help="LoRA adapter, local path, or shortcut: spider, bird, base",
200
+ )
201
+ parser.add_argument("--base-model", default=DEFAULT_BASE)
202
+ parser.add_argument("--evidence", default="", help="Optional BIRD-style domain hint")
203
+ parser.add_argument("--bf16", action="store_true", help="Load in bf16 instead of 4-bit")
204
+ parser.add_argument("--no-exec", action="store_true", help="Generate SQL without running it")
205
+ parser.add_argument("--max-new-tokens", type=int, default=256)
206
+ args = parser.parse_args()
207
+
208
+ db_path = Path(args.db)
209
+ if not db_path.exists():
210
+ sys.exit(f"Database not found: {db_path}")
211
+
212
+ model, tokenizer = load_model(
213
+ base_model=args.base_model,
214
+ adapter=args.adapter,
215
+ use_4bit=not args.bf16,
216
+ )
217
+ result = predict(
218
+ str(db_path),
219
+ args.question,
220
+ model,
221
+ tokenizer,
222
+ evidence=args.evidence,
223
+ execute=not args.no_exec,
224
+ max_new_tokens=args.max_new_tokens,
225
+ )
226
+ _print_result(result)
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main()