input → hidden → output: จุดแดงคือ activation ที่ไหลผ่านน้ำหนักจนได้คลาสที่ชนะ
ทั้งไฟล์ train.py (และ s12_train.py ของเรา) อ่านเป็นสี่จังหวะ ไล่เรียงกัน — จำโครงนี้ไว้ เดี๋ยวไล่ทีละอัน
เทียบกับบทเรียน 1.1–1.3: บทเรียน 1.1–1.3 มี 4 คำสั่ง (
models/select/result/stop) วันนี้ก็ 4 จังหวะ (build/fit/convert/eval) — ต่างที่ตอนนี้เราไม่ได้ "เรียกโมเดล" แต่ "สร้างโมเดล"
ก่อนดูโค้ดสร้างโมเดล มารู้จักเครื่องมือก่อน เราใช้ TensorFlow/Keras — วิธีเขียน neural network ที่อ่านเหมือน "ต่อบล็อกเลโก้" ทีละชั้น
tf.keras.Sequential([...]) — วางชั้น (layer) เรียงจากบนลงล่าง ข้อมูลไหลผ่านทีละชั้นไม่ต้องจำ API ของ Keras ทั้งหมดในชุดบทเรียนนี้ ขอแค่เห็นภาพว่า "โมเดล = ชั้นที่ต่อกัน" และข้อมูลไหลจาก input ผ่านชั้นต่างๆ ออกมาเป็นคลาส เดี๋ยวเราดูโค้ดจริงในสไลด์ถัดไป
โมเดลของเราคือ 1-D CNN เล็กๆ ตั้งใจให้เล็กพอลงชิป Conv1D กวาดไปตามแกนเวลา จับ "รูปร่างของการเคลื่อนไหว" ในหน้าต่าง 1 วินาที
def build_model(win, chans, n_classes):
return tf.keras.Sequential([
tf.keras.layers.Input(shape=(win, chans)), # (50, 6): 1 วิ, 6 แกน
tf.keras.layers.Conv1D(16, 5, padding="same", activation="relu"),
tf.keras.layers.MaxPooling1D(2),
tf.keras.layers.Conv1D(32, 3, padding="same", activation="relu"),
tf.keras.layers.GlobalAveragePooling1D(), # ยุบแกนเวลา
tf.keras.layers.Dense(32, activation="relu"),
tf.keras.layers.Dense(n_classes, activation="softmax"), # 3 คลาส
])
(win=50, chans=6) — หน้าต่าง 50 ตัวอย่าง (1 วิ ที่ 50 Hz) × 6 แกน IMU (ax..gz)ในฉบับเต็ม (
examples/) เราเรียกmodel.summary()+model.count_params()เพื่อดูว่าโมเดลเล็กแค่ไหน — โมเดลลงบอร์ดได้ต้องมีพารามิเตอร์ระดับหลักพัน ไม่ใช่หลักล้าน
kernel เดียวไถลทั้งเส้น = เรียน "รูปทรง" ที่เลื่อนไปมาได้ (translation-invariant)
Conv1D ที่เราเพิ่งวางในโมเดลไม่ใช่เวทมนตร์ มันคือสมการเดียวสั้นๆ ที่เลื่อน "หน้าต่างเล็ก" (filter) ไปตามแกนเวลา แล้วคูณ-บวกทีละตำแหน่ง:
อ่านทีละตัว (ภาษาคน):
ทำไมสำคัญตรงนี้: filter หนึ่งอันเรียน "ลายเซ็น" ของการเคลื่อนไหวหนึ่งแบบ — เช่น จังหวะสั่นถี่ๆ ของ
shakingการมี 16/32 filter ก็เหมือนมีนักสังเกต 16/32 คนช่วยกันจับลายต่างๆ พร้อมกัน นี่คือเหตุผลที่ Conv1D เหมาะกับสัญญาณตามเวลาแบบ IMU
▸ ลองเล่นสด (GeoGebra): เปิด Interactive Math Lab — ลากจุด/เลื่อนสไลเดอร์ดูสมการขยับตาม
แต่ละ epoch = ลูกบอลไถลลงหุบ loss เข้าใกล้จุดต่ำสุด (โมเดลทายแม่นขึ้นเรื่อย ๆ)
เมื่อมีโครงโมเดลแล้ว เรา compile (บอกว่าใช้ optimizer/loss อะไร) แล้ว fit (ฝึกจริง):
model.compile(optimizer="adam",
loss="sparse_categorical_crossentropy", metrics=["accuracy"])
model.fit(Xtr, ytr, validation_data=(Xva, yva),
epochs=a.epochs, batch_size=32, verbose=2)
Xva (ชุดที่ไม่ได้ฝึก) มาทดสอบ ดูว่าโมเดลเก่งจริงหรือแค่จำอ่านตัวเลขระหว่างฝึก:
| เห็นแบบนี้ | แปลว่า |
|---|---|
accuracy และ val_accuracy ขึ้นด้วยกัน |
ดี — โมเดลเข้าใจ generalize ได้ |
accuracy สูง แต่ val_accuracy ต่ำ/ตก |
overfit — จำข้อสอบ ไม่เข้าใจ |
| ทั้งคู่ต่ำค้าง | underfit — โมเดลเล็กไป/ข้อมูลน้อยไป/ฝึกสั้นไป |
fitคือช่องเติมที่ 1 ของคุณ — มันคือ "จังหวะที่โมเดลเรียนรู้จริง" ก่อนหน้านี้เราแค่วางโครง ยังไม่มีอะไรเกิดขึ้น
▸ ลองเล่นสด (GeoGebra): เปิด Interactive Math Lab — ลากจุด/เลื่อนสไลเดอร์ดูสมการขยับตาม
คะแนนดิบกลายเป็นความน่าจะเป็นที่รวมกันได้ 1 — ตัวสูงสุดคือคำตอบของโมเดล
ตอน model.fit(...) ทำงาน มีสามสมการหมุนวนอยู่ข้างใน ไม่ต้องท่อง แค่เห็นภาพว่าแต่ละตัวทำอะไร:
1. softmax — ชั้นสุดท้าย (Dense(..., activation="softmax")) แปลงคะแนนดิบ เป็นความน่าจะเป็นของ 3 คลาส ที่รวมกันได้ 1:
2. cross-entropy loss — คือ sparse_categorical_crossentropy ที่เราใส่ตอน compile วัดว่าคำทาย ห่างจากเฉลย แค่ไหน (ยิ่งทายถูกมั่นใจ ยิ่งเข้าใกล้ 0):
3. gradient descent — คือหัวใจของ optimizer adam ค่อยๆ ขยับน้ำหนัก ลง "เนินของ loss" ทีละก้าว:
อ่านสัญลักษณ์แบบภาษาคน:
ทำไมสำคัญตรงนี้: วงจร softmax → loss → gradient descent วนซ้ำทุก batch นี่แหละคือสิ่งที่ซ่อนอยู่หลัง
model.fit(...)บรรทัดเดียวที่คุณเติมในช่อง 1 พอเห็นaccuracyไต่ขึ้น = กำลังลดลง = กำลังขยับถูกทาง
โมเดลตอนฝึกใช้ตัวเลข float32 (ทศนิยม 32 บิต) แต่ NPU บนบอร์ดเร่งได้เฉพาะ int8 (จำนวนเต็ม 8 บิต) เราจึงต้อง "บีบ" ก่อนลงชิป
เราใช้ post-training quantization — ฝึกด้วย float ก่อน แล้วค่อยบีบทีหลัง ง่ายและพอสำหรับโมเดลเล็ก (มีวิธี quantization-aware training ที่แม่นกว่า แต่ซับซ้อนกว่า เก็บไว้เป็นการบ้านของนักวิจัย)
คำถามคือ converter จะรู้ได้ยังไงว่าควรเลือก "สเกล int8" เท่าไร? คำตอบ: เรา ป้อนตัวอย่างจริง ให้มันดู แล้วมันวัดช่วงค่าเอง — เรียกจังหวะนี้ว่า calibration
def representative():
for i in range(min(200, len(X_repr))):
yield [X_repr[i:i + 1].astype(np.float32)] # ป้อนหน้าต่างจริง 200 อัน
conv.representative_dataset = representative # ผูกเข้ากับ converter
ถ้าลืมผูก
representative_datasetทั้งที่ตั้งTFLITE_BUILTINS_INT8ไว้conv.convert()จะหยุดทันทีด้วยValueError: For full integer quantization, a representative_dataset must be specified.— นี่คือ ช่องเติมที่ 2 ของคุณ ส่วนความผิดพลาดที่เงียบกว่าและพบบ่อยในงานจริงคือป้อนตัวอย่างที่ไม่เหมือนข้อมูลจริง สเกลจะเพี้ยนแล้ว int8 แม่นตก
รวมทุกอย่างเข้าด้วยกัน: บอก converter ให้บีบ ทั้ง input และ output เป็น int8 (ไม่ใช่แค่น้ำหนักข้างใน) นี่คือคอนฟิกที่ NPU บังคับ
conv = tf.lite.TFLiteConverter.from_keras_model(model)
conv.optimizations = [tf.lite.Optimize.DEFAULT]
conv.representative_dataset = representative # ช่องเติม 2
conv.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
conv.inference_input_type = tf.int8 # ช่องเติม 3
conv.inference_output_type = tf.int8
tflite = conv.convert()
inference_input_type = tf.int8 + inference_output_type = tf.int8 — บังคับขาเข้า/ขาออกเป็น int8 ล้วนbytes ของไฟล์ .tflite เราเขียนลงดิสก์ตรงๆ ~11 KB
inference_input_type = tf.int8คือ ช่องเติมที่ 3 — สั้นแค่สองบรรทัด แต่คือเส้นแบ่งระหว่าง "โมเดลลงบอร์ดได้" กับ "ไม่ได้"
ก่อนฝึก เรา normalize ข้อมูล (ลบ mean หารด้วย std) เพื่อให้ทุกแกนอยู่สเกลเดียวกัน แต่มีกับดัก: ฝั่งที่เอาโมเดลไปใช้ต้อง normalize ด้วยค่าเดียวกันเป๊ะ
(Xtr, Xva, Xte), (mean, std) = dt.normalize(Xtr, Xva, Xte) # fit บนชุดฝึกเท่านั้น
...
np.savez(a.out + ".norm.npz", mean=mean, std=std) # เซฟไว้คู่กับโมเดล
dt.normalize หา mean/std จาก ชุดฝึกเท่านั้น ห้ามให้สถิติของ val/test รั่วเข้ามา (ไม่งั้นตัวเลขสวยเกินจริง)mean/std ลงไฟล์ .norm.npz คู่กับโมเดล — บทเรียน 5.6–5.7 (เบราว์เซอร์) และ บทเรียน 5.8–5.9 (บอร์ด) ต้องโหลดไปใช้นี่คือ จุดพังเงียบ (silent failure) ที่หายากมาก — โมเดลไม่ error แต่ทายผิดหมด เพราะ "เห็นข้อมูลคนละสเกล" กับตอนฝึก จำไว้: normalization เป็นส่วนหนึ่งของโมเดล ไม่ใช่ของแถม
มีไฟล์ .tflite แล้ว เรารันมันบน PC ผ่าน ai-edge-litert (ตัวรัน .tflite รุ่นใหม่) นี่คือ ground truth ก่อนเอาลงบอร์ด:
from ai_edge_litert.interpreter import Interpreter
it = Interpreter(model_path=out_path); it.allocate_tensors()
inp, out = it.get_input_details()[0], it.get_output_details()[0]
in_scale, in_zero = inp["quantization"] # สเกล/zero-point ของ input
for i in range(len(Xte)):
q = np.clip(np.round(Xte[i:i+1] / in_scale + in_zero), -128, 127).astype(np.int8)
it.set_tensor(inp["index"], q); it.invoke()
o = it.get_tensor(out["index"])[0].astype(np.float32)
o = (o - out_zero) * out_scale # dequantize กลับเป็น float
preds.append(int(o.argmax()))
acc = (preds == yte).mean() # ช่องเติม 4
argmax (คลาสที่ชนะ)acc = (preds == yte).mean() = สัดส่วนหน้าต่างที่ทายถูกในชุดทดสอบ — ช่องเติมที่ 4 ของคุณนี่คือ int8 math ที่เราเห็นครั้งแรกใน บทเรียน 1.1–1.3 (
conf/scores) แต่รอบนี้เราลงมือแปลงเอง เข้าใจว่าตัวเลข int8 บนชิปเชื่อมกับความน่าจะเป็นยังไง
accuracy บอกภาพรวม แต่ confusion matrix บอกว่าโมเดลสับสน "คู่ไหน" — สำคัญกว่าตัวเลขเดียวมากในงานจริง
คิดถึงบทเรียน false positive / false negative จาก โมดูล 4 (Analysis): confusion matrix คือที่ที่มันจับต้องได้ — "โมเดลชอบเข้าใจผิดว่า X เป็น Y" บอกเราว่าต้องเก็บข้อมูลเพิ่มตรงไหน