จบชุดบทเรียนนี้เราจะเดินครบ 4 เรื่อง แล้วปิดท้ายด้วย dataset ที่พร้อม train จริง:
sensors.bmi270.motion()s11_dataset.py เก็บ dataset ที่ สมดุล แล้วส่งต่อให้ dataset_tools.py แบ่งบน PCปลายทางของวันนี้: ไฟล์ gestures.csv ที่มี 3 คลาสสมดุล พร้อมแบ่ง train/val/test — dataset ที่ "train ได้จริง"
ชุดบทเรียนถัดไป (บทเรียน 5.3–5.5) เราจะเอา dataset นี้ไป train เป็นโมเดลจริงใน Docker — วันนี้คือการปูฐานให้โมเดลนั้นแม่น
บทเรียน 5.1–5.2 เป็นบทเรียน เปิด โมดูล 5 (Training) — เราหยุด "ใช้" โมเดลสำเร็จ แล้วเริ่ม "สร้าง" โมเดลของเราเอง โดยเริ่มจากสิ่งที่มาก่อนการ train เสมอ
s04_daq_logger.py) — เราจะต่อยอดจากตรงนั้น"กลับด้าน" ยังทำงาน: เห็นท่อสำเร็จก่อน (synthetic) แล้วย้อนเข้าใจ — พอถึง บทเรียน 5.3–5.9 คุณจะ train โมเดลตัวเองบน dataset ที่เก็บวันนี้
บทเรียน 2.1–2.2 เราเขียน s04_daq_logger.py ที่อ่าน IMU แล้วเขียนลง CSV — ชุดบทเรียนนี้ยืนบนโครงเดิมนั้นเป๊ะ แค่ยกระดับจาก "เก็บข้อมูล" เป็น "เก็บ dataset ที่ดีพอจะ train"
# หัวใจของ s04 — อ่าน sample แล้วเขียนลงไฟล์ (เราจะต่อยอดจากตรงนี้)
with open(PATH, "a") as f:
for _ in range(BURST):
ax, ay, az, gx, gy, gz = sensors.bmi270.motion()
f.write("%s,%.4f,%.4f,%.4f,%.4f,%.4f,%.4f\n"
% (label, ax, ay, az, gx, gy, gz))
time.sleep_ms(RATE_MS) # 20 ms = 50 Hz
ถ้ายังไม่แม่นเรื่องเขียน CSV บนบอร์ด เปิด
s04_daq_logger.pyอ่านทวนก่อน — ชุดบทเรียนนี้เพิ่มแค่ "สมองของการเตรียมข้อมูล" ทับลงไป
dataset ในงานของเราคือไฟล์ CSV ธรรมดา หนึ่งบรรทัดต่อหนึ่ง sample โดยมี ป้าย (label) บอกว่าตอนเก็บ sample นั้นเรากำลังทำท่าอะไร:
ax,ay,az,gx,gy,gz) คือค่าจาก sensors.bmi270.motion() — เหมือนที่โมเดล Motion บนบอร์ดกินเป๊ะlabel คือ "เฉลย" ที่มนุษย์ติดให้ ตอน train โมเดลจะเรียนความสัมพันธ์ "ค่า 6 ช่องนี้ → ป้ายนี้"เราจงใจใช้ 3 คลาสเดียวกับโมเดล Motion บนบอร์ด (
idle / circle / shaking) เพื่อจะเทียบโมเดลที่เรา train เอง กับของสำเร็จได้ในชุดบทเรียนหลัง
dataset ดิบยัง train ไม่ได้ทันที ต้องผ่าน 4 ขั้น กว่าจะกลายเป็นสิ่งที่โมเดลเรียนได้ — ชุดบทเรียนนี้เราทำครบทั้งเส้น:
ขั้น 1–2 (capture + label) เกิดบนบอร์ดด้วย MicroPython · ขั้น 3–4 (window + split) เกิดบน PC ด้วย
dataset_tools.py— ชุดบทเรียนนี้เราแตะทั้งสองฝั่ง
ก่อนเก็บข้อมูลจริง (ที่ใช้เวลานาน) เรารันท่อทั้งเส้นด้วย ข้อมูล synthesize ก่อน เพื่อพิสูจน์ว่าโค้ดทำงาน แล้วเห็นว่า dataset ที่ดี "หน้าตาเป็นยังไง":
# 1) สร้าง dataset ปลอม (idle=นิ่ง, circle=หมุน, shaking=สั่นแรง)
python dataset_tools.py --synthesize --out data/gestures.csv
# 2) ส่องว่าได้อะไร — จำนวน sample, จำนวนหน้าต่าง, จำนวนต่อคลาส
python dataset_tools.py --out data/gestures.csv
# samples (3600, 6) windows (143, 50, 6) class counts [48 48 47]
class counts [48 48 47] = จำนวนหน้าต่างของแต่ละคลาส ใกล้เคียงกัน → นี่คือ dataset ที่ สมดุลwindows (143, 50, 6) = 143 หน้าต่าง แต่ละหน้าต่างมี 50 sample × 6 ช่อง — นี่คือรูปร่างที่โมเดลกินเราให้
synthesizeไว้ตั้งแต่แรก เพื่อให้ท่อ "รันได้" ก่อนมีข้อมูลจริง — เป็นเชื้อเพลิงของการกลับด้าน: เห็นของสำเร็จก่อน แล้วค่อยเอาข้อมูลจริงมาแทน
ถ้าคลาสหนึ่งมี sample เยอะกว่าเพื่อนมาก โมเดลจะ "ขี้เกียจ" — มันเดาคลาสที่เจอบ่อยไว้ก่อนก็ถูกบ่อยแล้ว โดยไม่ต้องเรียนรู้จริง
idle โมเดลที่ตอบ "idle" ตลอดจะแม่น 95% บนกระดาษ แต่ใช้งานจริงไม่ได้เลยs11_dataset.py เฝ้าดูให้ชุดบทเรียนนี้เราจึงไม่เก็บข้อมูลแบบ "ดะ" แต่เก็บพร้อม แถบสมดุลต่อคลาส บนจอ ถ้าคลาสไหนน้อย โปรแกรมจะบอกให้เก็บเพิ่ม — เก็บอย่างมีสติ ไม่ใช่เก็บเยอะ
sample เดียว (ค่า IMU ณ วินาทีหนึ่ง) บอกท่าไม่ได้ — shaking กับ idle ที่จังหวะหนึ่งอาจมีค่าเท่ากันบังเอิญ โมเดลต้องเห็น ช่วงเวลา ถึงจะแยกออก (นี่คือแนวคิดจาก บทเรียน 4.5–4.6 ที่กลับมาใช้)
WIN = 50 (1 วินาทีที่ 50 Hz) คือ "ความยาวหนึ่งท่า" ที่โมเดลตัดสิน · HOP = 25 คือเลื่อนหน้าต่างทีละครึ่ง (ได้ข้อมูลมากขึ้นจากสายเดียว)make_windows() ใน dataset_tools.py ทำขั้นนี้ให้ — และมันคือ การจัดหน้าต่างแบบเดียวกับที่ feed บนบอร์ดทำ ตอนอนุมานจริงทำไมต้องเหมือนกันเป๊ะ? เพราะโมเดลที่ฝึกด้วยหน้าต่าง 50 sample จะทำงานถูกก็ต่อเมื่อตอนใช้จริงมันก็เห็นหน้าต่าง 50 sample เช่นกัน — "train เห็นแบบไหน ใช้จริงต้องเห็นแบบนั้น"
เราไม่เอาข้อมูลทั้งหมดไป train แล้ววัดความแม่นบนข้อมูลเดิม เพราะนั่นเหมือนให้ผู้เรียนดูเฉลยก่อนสอบ — ต้องกันข้อมูลไว้ "สอบจริง" ต่างหาก
split() ใน dataset_tools.py ทำให้ครบ 3 กอง ด้วยสัดส่วน val=0.15, test=0.15 (ที่เหลือ 70% เป็น train)ทำไมต้องมี val แยกจาก test? เพราะเราจูนโมเดลโดยดู val ซ้ำๆ — val จึง "ปนเปื้อน" การตัดสินใจของเราไปแล้ว test ที่ไม่เคยแตะเลยเท่านั้นถึงบอกความแม่นจริงได้
ถ้าแบ่งแบบสุ่มดื้อๆ อาจซวยได้ว่า test ดันไม่มี shaking เลย — วัดความแม่นของ shaking ไม่ได้ ทางแก้คือแบ่งแบบ stratified: แยกทีละคลาสแล้วค่อยหั่น
# หัวใจของ split() — แยก index ของแต่ละคลาสก่อน แล้วหั่น 70/15/15 ในแต่ละคลาส
idx = {c: rng.permutation(np.where(y == c)[0]) for c in np.unique(y)}
for c, ii in idx.items():
n = len(ii)
nte, nva = int(n * test), int(n * val)
te += list(ii[:nte]) # 15% แรกของคลาสนี้ → test
va += list(ii[nte:nte + nva]) # 15% ถัดไป → val
tr += list(ii[nte + nva:]) # ที่เหลือ → train
idle : circle : shaking เท่ากัน — ไม่มีคลาสไหนหายจากกองใดrng.permutation สลับก่อนหั่น เพื่อไม่ให้ลำดับการเก็บข้อมูล (เช่น เก็บ idle ทั้งหมดก่อน) มาทำให้กองเอนเอียง"stratified" แปลว่า "แบ่งเป็นชั้นตามคลาส" — เป็นค่าเริ่มต้นที่ควรใช้เสมอในงาน classification ที่คลาสมีความสำคัญเท่ากัน
สามแนวคิดที่เพิ่งดูไป เขียนเป็นสูตรสั้นๆ ได้ ช่วยให้เห็นว่า "สมดุล" กับ "แบ่ง" วัดกันด้วยตัวเลขจริง
สัดส่วนของแต่ละคลาส (class balance)
idle / circle / shaking)แบ่งเป็น 3 กอง (train / val / test)
อยากได้ ก็คูณ ด้วย 0.15 แล้วปัดลงให้ test และ val ที่เหลือเป็น train — นี่คือสิ่งที่
split()ทำให้ในบรรทัดint(n*test),int(n*val)
การแบ่งแบบ stratified ไม่ได้หั่นทั้งกองรวด แต่หั่น ทีละคลาส ด้วยสัดส่วนเดียวกัน แล้วเอามารวม
for c in np.unique(y) ในฟังก์ชัน split()สิ่งที่รับประกัน (invariant): สัดส่วนของทุกคลาสในทุกกองเท่ากับสัดส่วนเดิม
shaking มา หน้าต่าง test ก็ยังได้ราว หน้าต่าง ไม่หลุดเป็น 0 เหมือนสุ่มดื้อๆnp.bincount ที่เราสั่งพิมพ์ตอนตรวจ dataset — ตัวเลขทั้ง 3 กองจะสะท้อน เดียวกันนี่คือเหตุผลที่ "stratified" เป็นค่าเริ่มต้นที่ควรใช้เสมอ — คณิตข้างบนพิสูจน์ว่ามันกันเหตุ "test ไม่มี shaking" ได้ตั้งแต่ก่อน train
ข้อผิดพลาดที่ทำให้ตัวเลขความแม่น "สวยหลอกๆ" คือ ข้อมูลรั่ว (leakage) — ปล่อยให้สถิติของ val/test แอบเข้าไปมีอิทธิพลตอน train จุดที่พลาดบ่อยสุดคือตอน normalize
# ถูก: คิด mean/std จาก TRAIN เท่านั้น แล้วเอาไปใช้กับ val/test
def normalize(X_train, *others):
mean = X_train.reshape(-1, X_train.shape[-1]).mean(0)
std = X_train.reshape(-1, X_train.shape[-1]).std(0) + 1e-6
norm = lambda A: (A - mean) / std
return (norm(X_train), *[norm(o) for o in others]), (mean, std)
mean/std จากข้อมูล ทั้งหมด (รวม test) = test รั่วเข้า train แล้ว ความแม่นที่วัดได้จะดีเกินจริงleakage เป็นบั๊กที่ไม่ crash — โปรแกรมรันผ่าน ตัวเลขออกมาสวย แต่พอ deploy จริงกลับพัง นี่คือเหตุผลที่วิศวกร ML ระวังเรื่องนี้เป็นพิเศษ
ฝั่ง PC เรามีไฟล์เดียว dataset_tools.py (ให้ไว้แล้ว) ที่รวม 5 ฟังก์ชันของการเตรียมข้อมูล — ชุดบทเรียนนี้เราเรียกใช้มัน ไม่ต้องเขียนเอง แต่ต้องอ่านให้เข้าใจ:
| ฟังก์ชัน | ทำอะไร |
|---|---|
load_csv(path) |
อ่าน CSV ของบอร์ด → (samples[N,6], labels[N]) |
make_windows(s, l) |
ตัดสายเป็นหน้าต่างซ้อนกัน → (X[W,50,6], y[W]) |
normalize(X_train, ...) |
มาตรฐานต่อช่อง (fit บน train เท่านั้น) |
split(X, y) |
แบ่ง train/val/test แบบ stratified |
synthesize(path) |
สร้างข้อมูลปลอมให้ท่อรันได้ก่อนมีข้อมูลจริง |
CLASSES = ["idle","circle","shaking"], WIN = 50, HOP = 25 — ตรงกับที่บอร์ดเก็บเราให้
dataset_tools.pyมาแล้ว เพราะโฟกัสของ คุณ ชุดบทเรียนนี้คือ "เก็บ CSV ให้ดี" (ฝั่งบอร์ด) ส่วนการ split เป็นตรรกะ PC ที่อ่านเข้าใจแล้วเรียกใช้ได้