แผนการเรียนรู้
/
AMMS 302 | Week 8

Finding Insights with SQL

การสืบค้นเชิงลึกด้วย SQL

Subquery · CTE · CASE WHEN · Window (ROW_NUMBER/RANK) · Cohort · Time Trends

🎯 เป้าหมายคาบนี้

  • เปลี่ยนคำถามเชิงคลินิก → query ได้ (เช่น "หญิงเบาหวานควบคุมไม่ได้กี่คน?")
  • เขียน IN / EXISTS / correlated, CASE WHEN, WITH (CTE), ROW_NUMBER / RANK / AVG OVER() ได้ (CLO2)
  • สืบค้นเชิงลึกบน healthinfo.db (patients ⨯ prescriptions) พร้อม sanity-check จำนวนแถว ทุกครั้ง

🗺️ Learning Path

สไลด์ 2–3
คำถาม→query
สไลด์ 4–9
เครื่องมือใหม่
สไลด์ 10–11
Insight จริง
Lab + Cheat
สไลด์ 13–14
ต่อจาก healthinfo.db สัปดาห์ 5–7 — มี patients + prescriptions พร้อมแล้ว

สารบัญ — ข้ามไปหัวข้อที่ต้องการ

📚 สเปกตลอดคาบ: SELECT · Subquery · WITH (CTE) · Window Functions · CASE

01 · ทำไม "Insights" ต้องมากกว่า SELECT ธรรมดา?

ผู้บริหารไม่ได้ถาม "SELECT * คืออะไร" แต่ถาม "กลุ่มเสี่ยงไหนเพิ่มขึ้น คลินิกไหนแพงสุด ใครยังไม่ได้ยา?" — ต้องรวม WHERE + JOIN + GROUP + ตรรกะซ้อน

🩺 3 คำถามจริงบน healthinfo.db

คำถามต้องใช้อะไร
หญิง HbA1c>9 มีกี่คน และได้ยา Metformin ไหม?JOIN + CASE + subquery
Top-3 รายจ่ายยาสูงสุดต่อคน (ต่อเพศ)GROUP BY + window RANK
ผู้ป่วยที่ ยังไม่เคย ได้ยาเลยNOT EXISTS / anti-join
💡 ทุกคำถาม = ประกอบร่างบท 5–7 + บทนี้ — ไม่ใช่ syntax ใหม่ทั้งหมด

🔁 สูตรคิด 6 ขั้น (ขยายจาก wk09)

1. output คอลัมน์อะไร?
2. ตารางไหนมี? → FROM / JOIN
3. กรองแถว → WHERE (เทียบ wk06)
4. ซ้อน/แบ่งกลุ่ม → subquery / CASE / GROUP
5. จัดอันดับ/หน้าต่าง → window
6. เรียง/ตัด → ORDER BY / LIMIT + นับ COUNT เช็ค!
🔗 เทียบ pandas: df.query + groupby().rank() = window · merge = JOIN — แต่ SQL ทำงานใน DB ได้เลย

02 · Subquery — query ซ้อน query

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'
);
💡 IN = OR ยาวแบบอ่านง่าย (wk06) แต่คราวนี้ลิสต์มาจาก SELECT จริง

Table subquery (FROM)

-- หา avg ต่อ drug ก่อน แล้วค่อยกรอง
SELECT drug, avg_cost FROM (
  SELECT drug, AVG(cost_thb) AS avg_cost
  FROM prescriptions GROUP BY drug
) WHERE avg_cost > 50;
ต้องตั้ง alias ให้ derived table: FROM (SELECT …) AS t
ช้าเมื่อ: subquery ใน WHERE ไม่ correlated แต่รันซ้ำทุกแถว — พิจารณา CTE (สไลด์ 06)

03 · NOT IN กับดัก NULL vs EXISTS / Anti-JOIN

หา "ไม่มี" — 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
);

เทียบ 3 แบบ

วิธีNULL-safe?อ่านง่าย?
NOT IN (SELECT…)❌ ถ้ามี NULL → 0สั้น
NOT EXISTSต้อง correlated
LEFT … WHERE r.id IS NULLanti-join (wk07)
💡 สูตร: เจอ "ไม่มี/ไม่เคย" → นึก EXISTS / anti-join ก่อน NOT IN
-- 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;
📚 IN / NOT IN · EXISTS · สัปดาห์ 6: NULL logic

04 · Correlated Subquery vs JOIN

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 ข้างนอก
);
ระวัง: correlated = รัน subquery ทุกแถว → ช้าเมื่อตารางใหญ่

Rewrite เป็น JOIN + GROUP BY

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;
✅ อ่านง่าย + รันครั้งเดียว — นี่คือพลัง CTE (สไลด์ถัดไป)
💡 เทียบ pandas: df.groupby('drug').transform('mean') = correlated avg
📚 SELECT — correlated subquery · โน้ต wk07: JOIN + GROUP เร็วกว่าบ่อยครั้ง

05 · CASE WHEN — Bucket ค่าทางคลินิก

IF ใน 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;

BP staging (AHA)

CASE
  WHEN systolic_bp >= 180 THEN 'วิกฤต'
  WHEN systolic_bp >= 140 THEN 'สูง'
  WHEN systolic_bp >= 120 THEN 'ค่อนข้างสูง'
  ELSE 'ปกติ'
END AS bp_stage
a1cbucket
5.8ปกติ
7.2คุมไม่ได้
NULLยังไม่ได้วัด
💡 CASE + COUNT = cross-tab — เทียบ pandas pd.cut()

06 · CTE (WITH) — ตั้งชื่อ subquery ให้อ่านรู้เรื่อง

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;

เทียบ Subquery vs CTE

Nested ❌
SELECT … FROM (SELECT … FROM (SELECT …))
วงเล็บ 3 ชั้น อ่านยาก
CTE ✅
WITH a AS (…), b AS (…) SELECT … FROM a JOIN b
เรียงบน→ล่าง อ่านเป็นขั้น
💡 CTE + CASE = โค้ด insight 10–20 บรรทัดยังอ่านรู้เรื่อง — ตรงกับ pipeline ETL สัปดาห์ 4
CTE ซ้ำได้: WITH t AS (…) SELECT … FROM t UNION ALL SELECT … FROM t

07 · ROW_NUMBER / RANK / DENSE_RANK

Top-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;
totalROW_NUMBERRANKDENSE
103111
103211
77332
ROW_NUMBER: ไม่ซ้ำ · RANK: ซ้ำแล้วข้าม · DENSE: ซ้ำไม่ข้าม
❌: WHERE ROW_NUMBER() … <=3 ใน SELECT เดียวกันไม่ได — ต้องห่อ subquery/CTE

08 · Window Aggregates — AVG OVER()

อยากได้รายแถว + ค่าเฉลี่ยกลุ่มในแถวเดียวกัน — 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 vs Window

วิธีได้กี่แถวใช้เมื่อ
GROUP BY gender2 แถวสรุปกลุ่ม
AVG() OVER(PARTITION BY…)103 แถวเดิมเทียบรายคนกับค่าเฉลี่ย
OVER() = "หน้าต่าง" มองเห็นกลุ่ม แต่ไม่ยุบแถว
💡 เทียบ pandas: df.groupby('gender')['total'].transform('mean')

09 · Cohort Insights — รวมทุกเครื่องมือ

ตัวอย่าง: "หญิง อายุ≥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)
  • ไม่มี Metformin: NOT EXISTS (สไลด์ 03) — ปลอดภัยกว่า NOT IN
  • CTE: base ให้อ่านเป็นขั้น — ขยายต่อเป็น CASE/window ได้
✅ นับ sanity-check ก่อนส่ง: SELECT COUNT(*) FROM base เทียบหลัง NOT EXISTS

10 · Time Trends — เดือน/ปี + CASE

SQLite ไม่มี 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;

ใช้กับ prescriptions (มีวันที่จริง)

ถ้าเพิ่มคอลัมน์ 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;
💡 Cross-tab: CASE inside SUM = Pivot แนวนอน — เทียบ pandas pivot_table

11 · Pipeline 10 ขั้นฉบับ Insights

เพิ่ม CTE/Window ลงใน pipeline 8 ขั้นเดิม (wk07) — จำลำดับจริงที่เครื่องทำ

1 WITH clean AS (…) — CTE
↓ 2 FROM base c
3 JOIN spend s ON …
↓ 4 WHERE hba1c ≥7
5 GROUP BY gender
↓ 6 HAVING COUNT>2
7 SELECT … CASE … , AVG() OVER() …
↓ 8 WINDOW (PARTITION/ORDER)
9 ORDER BY spend DESC
↓ 10 LIMIT 5
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;
กับดัก: HAVING ใช้ alias จาก SELECT ไม่ได้ — ต้องซ้ำนิพจน์หรือห่อ CTE

12 · Workflow — ใช้ healthinfo.db ต่อ

ไม่สร้าง DB ใหม่ — ถ้าลบให้รัน week05-sql-basics.ipynb + week07-joins.ipynb ใหม่ก่อน

⭐ A: Jupyter

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)

B: DB Browser

scoop install sqlitebrowser
Open Database → healthinfo.db
Execute SQL → Run (รองรับ WITH + Window
ตั้งแต่ SQLite 3.25)
sqlitebrowser.org/about

C: CLI

scoop install sqlite
sqlite3 healthinfo.db
sqlite> SELECT sqlite_version();
 -- ≥3.25 window OK, ≥3.39 FULL JOIN OK
sqlite> .quit
sqlite.org/cli

13 · Lab สัปดาห์ที่ 8 🧪

ฝึกบน healthinfo.db — ไม่ต้อง notebook ใหม่ (ใช้ healthinfo.db ต่อ) — ส่งภารกิจในระบบ LMS ตามประกาศ

📋 ภารกิจ ↔ สไลด์

#ภารกิจสไลด์
1Subquery IN: คนได้ยา Metformin + นับ COUNT เช็ค02
2NOT EXISTS / anti-join: คนไม่เคยได้ยาเลย (เทียบ NOT IN trap)03
3CASE WHEN: bucket HbA1c → GROUP นับต่อ bucket05
4CTE: clean + spend แล้ว LEFT JOIN + ORDER BY total06
5Window: Top-3 รายจ่ายสูงสุด ต่อเพศ (ROW_NUMBER PARTITION)07
6Cohort: หญิง ≥60 + HbA1c≥7 + BP≥140 + NOT Metformin (สไลด์ 09)09

✅ Self-check + HW

  • IN vs EXISTS ให้ count เท่ากัน
  • CASE bucket รวม COUNT = 103 คนครบ
  • CTE รันได้ไม่ error (SQLite ≥3.25)
  • WindowTop-3 ต่อเพศ ได้ 6 แถว (3+3) — RN 1–3 ต่อกลุ่ม
Homework 8: สร้าง insight ใหม่ 1 ข้อ (เช่น "ชายวิกฤต≥9 ที่จ่ายแพงกว่าค่าเฉลี่ยเพศ") เขียนด้วย CTE + Window + CASE พร้อมอธิบาย 3 บรรทัดว่าตอบคำถามอะไร — ส่ง .sql หรือ screenshot + COUNT()

14 · Cheat Sheet — Insights Queries

แพทเทิร์นSyntaxใช้เมื่อตัวอย่างคลินิก
IN subqueryWHERE id IN (SELECT…)ลิสต์มาจาก DBได้ Metformin
NOT EXISTSWHERE NOT EXISTS (SELECT 1…)หา "ไม่มี"ยังไม่จ่ายยา
CASECASE WHEN … THEN … ENDbucketHbA1c / BP stage
CTEWITH t AS (…) SELECT…อ่านง่าย reuseclean + spend
ROW_NUMBERROW_NUMBER() OVER(PARTITION BY … ORDER BY …)Top-N ต่อกลุ่ม ไม่ซ้ำTop-3 ต่อเพศ
RANKRANK() OVER(…)ซ้ำแล้วข้ามจัดอันดับยาแพง
AVG OVERAVG(col) OVER(PARTITION BY …)เทียบรายคน vs กลุ่มแพงกว่าค่าเฉลี่ยเพศ
Golden rules: วงเล็บ IN ชัดเจน · NOT IN หลีกเมื่อมี NULL → EXISTS · CTE อ่านบน→ล่าง · Window ต้องห่อก่อน WHERE · CASE ครอบ NULL ก่อน

15 · แหล่งอ้างอิงทางการ

🛠️ เสริม

📬 ติดต่อ

ดร.วิชิต สมบัติ — wichit.s@ubu.ac.th

คาบหน้า: สอบกลางภาค (Week 9) — ทบทวน wk05–08 + cheat sheets!