การสืบค้นเชิงลึกด้วย SQL
Subquery · CTE · CASE WHEN · Window (ROW_NUMBER/RANK) · Cohort · Time Trends
IN / EXISTS / correlated, CASE WHEN, WITH (CTE), ROW_NUMBER / RANK / AVG OVER() ได้ (CLO2)healthinfo.db (patients ⨯ prescriptions) พร้อม sanity-check จำนวนแถว ทุกครั้งhealthinfo.db สัปดาห์ 5–7 — มี patients + prescriptions พร้อมแล้วผู้บริหารไม่ได้ถาม "SELECT * คืออะไร" แต่ถาม "กลุ่มเสี่ยงไหนเพิ่มขึ้น คลินิกไหนแพงสุด ใครยังไม่ได้ยา?" — ต้องรวม WHERE + JOIN + GROUP + ตรรกะซ้อน
| คำถาม | ต้องใช้อะไร |
|---|---|
| หญิง HbA1c>9 มีกี่คน และได้ยา Metformin ไหม? | JOIN + CASE + subquery |
| Top-3 รายจ่ายยาสูงสุดต่อคน (ต่อเพศ) | GROUP BY + window RANK |
| ผู้ป่วยที่ ยังไม่เคย ได้ยาเลย | NOT EXISTS / anti-join |
df.query + groupby().rank() = window · merge = JOIN — แต่ SQL ทำงานใน DB ได้เลยScalar (1 ค่า) · List (หลายค่า IN) · Table (FROM) — วงเล็บ = นิพจน์
-- Scalar: เทียบค่าเฉลี่ยทั้ง รพ. SELECT hn, hba1c FROM patients WHERE hba1c > (SELECT AVG(hba1c) FROM patients); -- List: คนที่มียา Metformin (จาก prescriptions) SELECT hn FROM patients WHERE patient_id IN ( SELECT patient_id FROM prescriptions WHERE drug='Metformin' );
-- หา avg ต่อ drug ก่อน แล้วค่อยกรอง SELECT drug, avg_cost FROM ( SELECT drug, AVG(cost_thb) AS avg_cost FROM prescriptions GROUP BY drug ) WHERE avg_cost > 50;
FROM (SELECT …) AS tหา "ไม่มี" — 3 วิธี เทียบความปลอดภัย: NOT IN ⚠️ / NOT EXISTS ✅ / LEFT IS NULL ✅
-- อยากได้: คนที่ "ไม่เคย" ได้ยา -- ⚠️ เสี่ยง 0 แถวถ้าลิสต์มี NULL SELECT hn FROM patients WHERE patient_id NOT IN ( SELECT patient_id FROM prescriptions ); -- ถ้า prescriptions.patient_id มี NULL → 0 แถว! -- ✅ ปลอดภัย SELECT hn FROM patients p WHERE NOT EXISTS ( SELECT 1 FROM prescriptions r WHERE r.patient_id = p.patient_id );
| วิธี | NULL-safe? | อ่านง่าย? |
|---|---|---|
| NOT IN (SELECT…) | ❌ ถ้ามี NULL → 0 | สั้น |
| NOT EXISTS | ✅ | ต้อง correlated |
| LEFT … WHERE r.id IS NULL | ✅ | anti-join (wk07) |
-- anti-join เทียบ wk07 สไลด์ 04 SELECT p.hn FROM patients p LEFT JOIN prescriptions r ON r.patient_id=p.patient_id WHERE r.rx_id IS NULL;
Correlated = อ้างตารางนอก (รันต่อแถว) — มักเขียนใหม่เป็น JOIN/CTE ได้เร็วกว่า
-- Correlated: ค่าใช้จ่าย > ค่าเฉลี่ยของตัวเองต่อ drug SELECT p.hn, r.drug, r.cost_thb FROM prescriptions r JOIN patients p ON p.patient_id=r.patient_id WHERE r.cost_thb > ( SELECT AVG(cost_thb) FROM prescriptions WHERE drug = r.drug -- ← อ้าง r ข้างนอก );
WITH avg_by_drug AS ( SELECT drug, AVG(cost_thb) avg_c FROM prescriptions GROUP BY drug ) SELECT p.hn, r.drug, r.cost_thb FROM prescriptions r JOIN avg_by_drug a USING(drug) JOIN patients p ON p.patient_id=r.patient_id WHERE r.cost_thb > a.avg_c;
df.groupby('drug').transform('mean') = correlated avgIF ใน SQL — ทำหมวด HbA1c / BP / cost tier แล้ว GROUP ต่อได้ทันที
SELECT hn, hba1c,
CASE
WHEN hba1c IS NULL THEN 'ยังไม่ได้วัด'
WHEN hba1c < 6.5 THEN 'ปกติ'
WHEN hba1c < 7.0 THEN 'เฝ้าระวัง'
WHEN hba1c < 9.0 THEN 'คุมไม่ได้'
ELSE 'วิกฤต ≥9'
END AS a1c_bucket
FROM patients LIMIT 5;
-- ใช้ใน GROUP BY ได้เลย
SELECT a1c_bucket, COUNT(*) n
FROM (SELECT CASE WHEN … END AS a1c_bucket FROM patients)
GROUP BY a1c_bucket;
CASE WHEN systolic_bp >= 180 THEN 'วิกฤต' WHEN systolic_bp >= 140 THEN 'สูง' WHEN systolic_bp >= 120 THEN 'ค่อนข้างสูง' ELSE 'ปกติ' END AS bp_stage
| a1c | bucket |
|---|---|
| 5.8 | ปกติ |
| 7.2 | คุมไม่ได้ |
| NULL | ยังไม่ได้วัด |
pd.cut()Subquery ยาว → ตั้งชื่อบนสุด → SELECT หลักอ่านสั้น — reuse ได้หลายครั้ง
WITH clean AS (
SELECT patient_id, hn, gender, hba1c
FROM patients
WHERE hba1c IS NOT NULL
AND birth_date LIKE '____-__-__'
), spend AS (
SELECT patient_id,
SUM(cost_thb) total, COUNT(*) n_rx
FROM prescriptions GROUP BY patient_id
)
SELECT c.hn, c.hba1c, s.total
FROM clean c
LEFT JOIN spend s USING(patient_id)
ORDER BY s.total DESC NULLS LAST LIMIT 5;
SELECT … FROM (SELECT … FROM (SELECT …))WITH a AS (…), b AS (…) SELECT … FROM a JOIN bWITH t AS (…) SELECT … FROM t UNION ALL SELECT … FROM tTop-N ต่อกลุ่ม (per gender / per drug) — ไม่ต้อง GROUP แล้ว self-join ให้วุ่น
-- Top-3 ค่าใช้จ่ายต่อคน "ต่อเพศ"
WITH spend AS (
SELECT patient_id, SUM(cost_thb) total
FROM prescriptions GROUP BY patient_id
)
SELECT p.gender, p.hn, s.total,
ROW_NUMBER() OVER(PARTITION BY p.gender
ORDER BY s.total DESC) rn,
RANK() OVER(PARTITION BY p.gender
ORDER BY s.total DESC) rk
FROM spend s JOIN patients p USING(patient_id)
-- กรอง Top-3 ต่อเพศ
-- WHERE rn <=3 ❌ ใช้ใน outer query!
;
SELECT * FROM (
SELECT …, ROW_NUMBER() OVER(…) rn FROM …
) WHERE rn <= 3;
| total | ROW_NUMBER | RANK | DENSE |
|---|---|---|---|
| 103 | 1 | 1 | 1 |
| 103 | 2 | 1 | 1 |
| 77 | 3 | 3 | 2 |
WHERE ROW_NUMBER() … <=3 ใน SELECT เดียวกันไม่ได — ต้องห่อ subquery/CTEอยากได้รายแถว + ค่าเฉลี่ยกลุ่มในแถวเดียวกัน — GROUP BY ทำไม่ได้ ต้อง OVER()
-- เทียบรายจ่ายยาต่อคน vs ค่าเฉลี่ยเพศเดียวกัน WITH spend AS ( SELECT patient_id, SUM(cost_thb) total FROM prescriptions GROUP BY patient_id ) SELECT p.hn, p.gender, s.total, AVG(s.total) OVER(PARTITION BY p.gender) avg_by_gender, s.total - AVG(s.total) OVER(PARTITION BY p.gender) diff, AVG(s.total) OVER() avg_all -- ทั้ง รพ. FROM spend s JOIN patients p USING(patient_id) ORDER BY diff DESC LIMIT 5;
| วิธี | ได้กี่แถว | ใช้เมื่อ |
|---|---|---|
| GROUP BY gender | 2 แถว | สรุปกลุ่ม |
| AVG() OVER(PARTITION BY…) | 103 แถวเดิม | เทียบรายคนกับค่าเฉลี่ย |
df.groupby('gender')['total'].transform('mean')ตัวอย่าง: "หญิง อายุ≥60, HbA1c≥7, มี HTN (BP≥140), ไม่เคยได้ Metformin" — query เดียวตอบได้
WITH base AS (
SELECT patient_id, hn, gender,
CAST((julianday('now')-julianday(birth_date))/365.25 AS INT) age,
hba1c, systolic_bp
FROM patients
WHERE gender='หญิง' AND hba1c >= 7
AND systolic_bp >= 140
AND birth_date LIKE '____-__-__'
)
SELECT b.hn, b.age, b.hba1c, b.systolic_bp
FROM base b
WHERE NOT EXISTS (
SELECT 1 FROM prescriptions r
WHERE r.patient_id=b.patient_id
AND r.drug='Metformin'
)
ORDER BY b.hba1c DESC LIMIT 10;
julianday (wk06) + กรอง LIKE '____-__-__' ตัดวันที่ผิดรูปAND + วงเล็บ (wk06 สไลด์ 04)NOT EXISTS (สไลด์ 03) — ปลอดภัยกว่า NOT INbase ให้อ่านเป็นขั้น — ขยายต่อเป็น CASE/window ได้SELECT COUNT(*) FROM base เทียบหลัง NOT EXISTSSQLite ไม่มี DATE type — ใช้ strftime ตัดเดือน แล้ว GROUP/CASE ต่อ
-- เทรนด์ผู้ป่วยใหม่ต่อเดือน (จาก birth_date เป็น proxy)
SELECT strftime('%Y-%m', birth_date) AS ym,
COUNT(*) n_new,
AVG(hba1c) avg_a1c
FROM patients
WHERE birth_date LIKE '____-__-__'
GROUP BY ym
ORDER BY ym LIMIT 6;
-- ผูกกับ bucket
SELECT ym,
SUM(CASE WHEN a1c_bucket='วิกฤต ≥9' THEN 1 ELSE 0 END) n_critical
FROM (SELECT strftime('%Y-%m',birth_date) ym,
CASE WHEN hba1c>=9 THEN 'วิกฤต ≥9' ELSE 'อื่น' END a1c_bucket
FROM patients WHERE birth_date LIKE '____-__-__')
GROUP BY ym;
ถ้าเพิ่มคอลัมน์ rx_date TEXT ISO8601 ใน prescriptions (HW wk07) →
SELECT strftime('%Y-%m', rx_date) ym,
drug, SUM(cost_thb) spend
FROM prescriptions
WHERE rx_date BETWEEN '2025-01-01' AND '2025-12-31'
GROUP BY ym, drug
ORDER BY ym, spend DESC;
pivot_tableเพิ่ม CTE/Window ลงใน pipeline 8 ขั้นเดิม (wk07) — จำลำดับจริงที่เครื่องทำ
WITH clean AS ( SELECT patient_id, hn, gender, hba1c FROM patients WHERE hba1c IS NOT NULL ) SELECT gender, CASE WHEN hba1c>=9 THEN 'วิกฤต' ELSE 'อื่น' END bucket, COUNT(*) n, AVG(hba1c) avg_a1c, RANK() OVER(PARTITION BY gender ORDER BY AVG(hba1c) DESC) rnk FROM clean GROUP BY gender, bucket HAVING COUNT(*) > 1 ORDER BY gender, rnk LIMIT 5;
ไม่สร้าง DB ใหม่ — ถ้าลบให้รัน week05-sql-basics.ipynb + week07-joins.ipynb ใหม่ก่อน
uv run jupyter lab
import sqlite3, pandas as pd
con=sqlite3.connect("healthinfo.db")
pd.read_sql("""
WITH spend AS (
SELECT patient_id, SUM(cost_thb) total
FROM prescriptions GROUP BY patient_id)
SELECT p.hn, s.total,
RANK() OVER(ORDER BY s.total DESC) rnk
FROM spend s JOIN patients p USING(patient_id)
LIMIT 5""", con)
scoop install sqlitebrowser Open Database → healthinfo.db Execute SQL → Run (รองรับ WITH + Window ตั้งแต่ SQLite 3.25)sqlitebrowser.org/about
scoop install sqlite sqlite3 healthinfo.db sqlite> SELECT sqlite_version(); -- ≥3.25 window OK, ≥3.39 FULL JOIN OK sqlite> .quitsqlite.org/cli
ฝึกบน healthinfo.db — ไม่ต้อง notebook ใหม่ (ใช้ healthinfo.db ต่อ) — ส่งภารกิจในระบบ LMS ตามประกาศ
| # | ภารกิจ | สไลด์ |
|---|---|---|
| 1 | Subquery IN: คนได้ยา Metformin + นับ COUNT เช็ค | 02 |
| 2 | NOT EXISTS / anti-join: คนไม่เคยได้ยาเลย (เทียบ NOT IN trap) | 03 |
| 3 | CASE WHEN: bucket HbA1c → GROUP นับต่อ bucket | 05 |
| 4 | CTE: clean + spend แล้ว LEFT JOIN + ORDER BY total | 06 |
| 5 | Window: Top-3 รายจ่ายสูงสุด ต่อเพศ (ROW_NUMBER PARTITION) | 07 |
| 6 | Cohort: หญิง ≥60 + HbA1c≥7 + BP≥140 + NOT Metformin (สไลด์ 09) | 09 |
| แพทเทิร์น | Syntax | ใช้เมื่อ | ตัวอย่างคลินิก |
|---|---|---|---|
| IN subquery | WHERE id IN (SELECT…) | ลิสต์มาจาก DB | ได้ Metformin |
| NOT EXISTS | WHERE NOT EXISTS (SELECT 1…) | หา "ไม่มี" | ยังไม่จ่ายยา |
| CASE | CASE WHEN … THEN … END | bucket | HbA1c / BP stage |
| CTE | WITH t AS (…) SELECT… | อ่านง่าย reuse | clean + spend |
| ROW_NUMBER | ROW_NUMBER() OVER(PARTITION BY … ORDER BY …) | Top-N ต่อกลุ่ม ไม่ซ้ำ | Top-3 ต่อเพศ |
| RANK | RANK() OVER(…) | ซ้ำแล้วข้าม | จัดอันดับยาแพง |
| AVG OVER | AVG(col) OVER(PARTITION BY …) | เทียบรายคน vs กลุ่ม | แพงกว่าค่าเฉลี่ยเพศ |
ดร.วิชิต สมบัติ — wichit.s@ubu.ac.th
คาบหน้า: สอบกลางภาค (Week 9) — ทบทวน wk05–08 + cheat sheets!