แผนการเรียนรู้
/

Introduction to PyTorch
Simple Perceptron และการฝึกโมเดล

uv - tensors - คณิตศาสตร์ของนิวรอน - training loop

ผลลัพธ์การเรียนรู้

1. สร้างโปรเจกต์ Python ใหม่ด้วย uv ได้จากศูนย์
2. อธิบายสมการคณิตศาสตร์ของนิวรอนเดียวได้
3. เขียนเพอร์เซปตรอนด้วย torch.nn ได้
4. เขียน training loop (forward/backward/update) ได้
สัปดาห์ที่ 9 - 1145208 การเรียนรู้เชิงลึก

เส้นทางของวันนี้: จากศูนย์ -> โมเดลแรกของคุณ

Part 1 - เครื่องมือ
เริ่มจากศูนย์ด้วย uv
ติดตั้ง uv -> uv init -> uv add torch -> รันได้บนทุกเครื่องเหมือนกัน
Part 2 - คณิตศาสตร์
นิวรอนหนึ่งตัวคืออะไร
\(z = w \cdot x + b\), activation (sigmoid), loss function และ gradient descent
Part 3 - ลงมือทำ
เพอร์เซปตรอนใน PyTorch
แปลงสมการเป็น nn.Linear + nn.Sigmoid
Part 4 - ฝึกโมเดล
Training loop
forward -> loss -> backward -> update แล้วดู loss ลด accuracy ขึ้นด้วยตาตัวเอง

Part 1: เริ่มจากศูนย์ -- ติดตั้งเครื่องมือ

uv คือตัวจัดการโปรเจกต์ Python แบบ all-in-one (แทน pip + venv + requirements.txt)

macOS (Homebrew)

$ /bin/bash -c "$(curl -fsSL \
    https://raw.githubusercontent.com/\
Homebrew/install/HEAD/install.sh)"

$ brew install uv
$ uv --version
uv 0.9.x

Linux

$ curl -LsSf \
    https://astral.sh/uv/install.sh | sh

$ uv --version
uv 0.9.x

Windows (PowerShell)

PS> Set-ExecutionPolicy RemoteSigned `
    -Scope CurrentUser
PS> Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression

PS> scoop install git   # git ก่อน
PS> scoop install uv
PS> uv --version

[OK] เมื่อ uv --version ทำงานได้ = พร้อมไปต่อ

สร้างโปรเจกต์ใหม่: uv init

$ uv init --no-package perceptron
$ cd perceptron

perceptron/
├── .python-version   # lock เวอร์ชัน Python
├── main.py           # ไฟล์เริ่มต้น
└── pyproject.toml    # ศูนย์กลาง config

- pyproject.toml คือบัตรประชาชนของโปรเจกต์ -- บอกชื่อ, เวอร์ชัน, dependencies

- .python-version ทำให้ทุกเครื่องใช้ Python เวอร์ชันเดียวกัน

- ไม่ต้องสร้าง venv เอง -- uv จัดการให้อัตโนมัติ

ติดตั้ง PyTorch: uv add torch

$ uv add torch
Resolved 12 packages
 + numpy==2.x.x
 + sympy==1.14.0
 + torch==2.x.x

$ uv run main.py     # รันใน venv ของโปรเจกต์
Hello from perceptron-lab!

- uv สร้าง virtualenv + ติดตั้ง + บันทึกเวอร์ชันลง uv.lock ให้เอง

- uv run ทุกครั้ง = ไม่มีปัญหา "รันใน env ผิด"

[Tip] เครื่องที่ clone ไปแค่ uv sync ก็ได้ environment เหมือนกันเป๊ะ

เช็กว่า torch พร้อมใช้

# check.py
import torch

print("torch:", torch.__version__)
print("device:",
      "cuda" if torch.cuda.is_available()
      else "mps" if torch.backends.mps.is_available()
      else "cpu")

t = torch.tensor([[1.0, 2.0],
                  [3.0, 4.0]])
print(t @ t.T)   # matrix multiply

- Tensor คือ n-dimensional array ของ PyTorch (คล้าย ndarray ของ NumPy)

- tensor เก็บ computation graph ไว้ให้ด้วย -> คำนวณ derivative อัตโนมัติ (autograd)

$ uv run check.py
torch: 2.x.x
device: cpu
tensor([[ 5., 11.],
        [11., 25.]])

Part 2: คณิตศาสตร์ -- นิวรอนหนึ่งตัว

เพอร์เซปตรอน = ฟังก์ชันทางคณิตศาสตร์ชุดเดียว

\(z = w \cdot x + b\)

weighted sum ของ input ทุกตัว บวก bias

Input x

features ของข้อมูล เช่น (รัศมีเนื้องอก, ขนาด) = [1.2, 0.8]

Weights w, bias b

ตัวเลขที่โมเดลจะเรียนรู้ -- ยิ่ง w มาก feature นั้นยิ่งมีน้ำหนัก

Output

z เป็นตัวเลขเท่าไรก็ได้ -> ต้องแปลงเป็น "ความมั่นใจ" ก่อน

Activation function: แปลง z -> คำตอบ

Step (เพอร์เซปตรอนดั้งเดิม 1958)

\(\hat{y} = 1 \text{ if } z \geq 0, \text{ else } 0\)

ตัดสินใจเด็ดขาด แต่... ไม่มีอนุพันธ์ -> ฝึกด้วย gradient descent ไม่ได้

Sigmoid (ที่เราจะใช้)

\(\sigma(z) = \dfrac{1}{1 + e^{-z}}\)

บีบค่าเข้าช่วง (0, 1) = probability [OK] smooth [OK] มีอนุพันธ์ทุกจุด [OK]

ความนุ่มของ sigmoid คือกุญแจที่ทำให้ "การเรียนรู้" เป็นไปได้

Loss function: วัดว่าโมเดลผิดแค่ไหน

ยิ่ง loss น้อย ยิ่ง prediction ตรงกับคำตอบจริง

MSE (Mean Squared Error) -- งาน regression

\(L = \dfrac{1}{N} \sum (\hat{y}_i - y_i)^2\)

ระยะห่างกำลังสองระหว่างทำนาย vs จริง

BCE (Binary Cross-Entropy) -- งาน binary classification

\(L = -\big[y \log \hat{y} + (1-y) \log(1-\hat{y})\big]\)

ทำนายผิดฝั่ง = ลงโทษหนักมาก (log พุ่งไป -inf)

[Goal] เป้าหมายของ training: ปรับ w, b ให้ L ต่ำที่สุด

Gradient Descent: ลงเนินหา loss ต่ำสุด

\(w \leftarrow w - \eta \cdot \dfrac{\partial L}{\partial w}\)

ขยับ weight ทีละน้อย ตามทิศทางที่ loss ลดลง

dL/dw

อนุพันธ์ = ทิศทาง + ความชัน ที่บอกว่า loss จะแยก/ดีขึ้นถ้าขยับ w

eta (learning rate)

ก้าวใหญ่ไป = กระโดดข้ามหลุม, ก้าวเล็กไป = ช้ามาก (เราใช้ 0.1)

autograd

loss.backward() คำนวณอนุพันธ์ทุกตัวแทนเรา -- ไม่ต้องไล่สมการเอง

[Loop] repeat: forward -> backward -> update จน loss ลดพอใจ

Part 3: สมการ -> PyTorch code

สมการ \(\hat{y} = \sigma(w \cdot x + b)\)
-> nn.Linear(2, 1) = \(w \cdot x + b\)
   (เก็บ w, b ไว้ให้และเรียนรู้เอง)
-> nn.Sigmoid() = \(\sigma(z)\)

import torch

model = torch.nn.Sequential(
    torch.nn.Linear(2, 1),   # z = w·x + b
    torch.nn.Sigmoid(),      # ŷ = σ(z)
)

# ลอง peek ค่าเริ่มต้น:
print(model[0].weight)   # tensor([[...]])
print(model[0].bias)     # tensor([...])
y_hat = model(torch.randn(5, 2))  # ทาย 5 จุด

model เริ่มจาก w, b สุ่ม ๆ -- ยัง "โง่" อยู่ ต่อไปคือทำให้ฉลาด

Part 4: Training loop ฉบับเต็ม -- train.py

import torch

torch.manual_seed(42)

# ข้อมูลจำลอง: 200 จุดใน 2 มิติ แบ่งด้วยเส้นตรง 1.5x + y > 0.5
N = 200
X = torch.randn(N, 2)
y = ((X[:, 0] * 1.5 + X[:, 1] - 0.5)
     > 0).float().unsqueeze(1)

model = torch.nn.Sequential(
    torch.nn.Linear(2, 1),
    torch.nn.Sigmoid(),
)
loss_fn = torch.nn.BCELoss()
opt = torch.optim.SGD(model.parameters(), lr=0.1)

for epoch in range(200):

  1. y_hat = model(X) # forward
  2. loss = loss_fn(y_hat, y) # วัด
  3. opt.zero_grad()
  4. loss.backward() # autograd
  5. opt.step() # update w, b

[!] ลืม zero_grad() = gradient ทับกัน โมเดลพังแบบเข้าใจยาก!

Backpropagation: คำนวณ gradient ย้อนกลับด้วย Chain Rule

Forward pass -> คำนวณ loss

z  = w₁·x₁ + w₂·x₂ + b     ← linear
a  = sigmoid(z)              ← activation
L  = BCE(a, y)               ← loss

Backward pass -> คำนวณ gradient

dL/da = ...                   ← loss → activation
da/dz = sigmoid'(z)           ← activation → z
dz/dw₁ = x₁                  ← z → w₁

dL/dw₁ = dL/da · da/dz · dz/dw₁   ← chain rule!

[Tip] Chain rule คูณ derivatives ย้อนกลับทีละชั้น -- เหมือนกระโดด rope ย้อนจาก loss กลับมายัง w ทุกตัว

- Forward pass: ใส่ข้อมูล -> โมเดลคำนวณ -> ได้คำตอบ + loss

- Backward pass: จาก loss คำนวณย้อนกลับว่า "w แต่ละตัวมีส่วนทำให้ loss ผิดไปเท่าไหร่"

- \(\frac{\partial L}{\partial w}\) = ถ้าเพิ่ม w นิดเดียว loss จะเปลี่ยนเท่าไหร่ -> ทิศทางที่ควรปรับ

- โมเดล 1 ชั้น = chain rule 1 ชั้น / โมเดล 100 ชั้น = chain rule 100 ชั้น -> นี่คือ "deep" ใน deep learning

[Tip] ย้อนกลับจาก loss -> output -> hidden -> input เรียกว่า "back" propagation เพราะเริ่มจาก "back" (ท้าย)

PyTorch Autograd: ให้เครื่องคำนวณ gradient ให้เรา

import torch

# 1) requires_grad=True → ให้ PyTorch ติดตาม
w = torch.tensor(1.0, requires_grad=True)

# 2) Forward pass — PyTorch สร้าง computational graph
x = torch.tensor(2.0)
z = w * x          # z = 2w
loss = (z - 3)**2  # loss = (2w - 3)²

# 3) Backward pass — คำนวณ dloss/dw อัตโนมัติ
loss.backward()
print(w.grad)      # tensor(4.)  ← dloss/dw = 4(2w-3)

# 4) Update — optimizer ใช้ gradient ปรับ w
opt = torch.optim.SGD([w], lr=0.1)
opt.step()          # w = 1.0 - 0.1*4.0 = 0.6

- requires_grad=True = ป้ายบอก PyTorch "เก็บ operation ทุกขั้นตอนไว้"

- ทุกครั้งที่คำนวณ (forward) PyTorch สร้าง computational graph เบื้องหลัง

- loss.backward() = เดินย้อน graph เรียก chain rule อัตโนมัติ -> ผลอยู่ใน w.grad

- optimizer.step() = ใช้ gradient ปรับน้ำหนัก: w = w - lr * grad

- zero_grad() = ล้าง gradient เก่าก่อนรอบใหม่ -- ไม่งั้น gradient บวกทับกัน

[Tip] ใน train.py: 3 บรรทัดนี้ = หัวใจของการเทรน -- loss.backward() + opt.step() + zero_grad()

สร้างโมเดล: PyTorch vs Keras

PyTorch

import torch.nn as nn

model = nn.Sequential(
    nn.Linear(2, 1),    # ← กำหนด (in, out)
    nn.Sigmoid(),
)
print(model)
# Linear(in_features=2,
#        out_features=1)

- nn.Linear(2,1) -- บอก input/output ตรง ๆ ไม่ต้องมี input_shape

- ไม่มี summary() สำเร็จรูป ต้อง print(model) เอง

Keras

import keras

model = keras.Sequential([
    keras.layers.Dense(1,
        activation='sigmoid',
        input_shape=(2,)),   # ← กำหนด input
])
model.summary()
# Layer (type)   Output    Param
# dense (Dense)  (None,1)  3

- input_shape=(2,) -- บอก shape อินพุตครั้งเดียวในชั้นแรก

- model.summary() -- นับพารามิเตอร์ให้ทันที (2*1+1=3)

/Instruction เหมือนกัน: กำหนดชั้น -> ใส่ activation -> เตรียมเทรน ต่างกันแค่ syntax

ฝึกโมเดล: PyTorch vs Keras

PyTorch -- เขียน loop เอง

loss_fn = nn.BCELoss()
opt = torch.optim.SGD(
    model.parameters(), lr=0.1)

for epoch in range(200):
    y_hat = model(X)          # 1. forward
    loss = loss_fn(y_hat, y)  # 2. คำนวณ loss
    opt.zero_grad()           # 3. ล้าง gradient
    loss.backward()           # 4. autograd
    opt.step()                # 5. update w,b

[OK] เข้าถึงทุกขั้นตอน -- custom loop, multi-task, debug ง่าย

[!] เขียนมากกว่า -- ลืม zero_grad ได้

Keras -- ให้ model.fit() จัดการ

model.compile(
    loss='binary_crossentropy',
    optimizer='sgd',
    metrics=['accuracy']
)
model.fit(X, y,
          epochs=200,
          verbose=1)
# loss: 0.22 - acc: 0.99

[OK] 2 บรรทัดจบ -- callbacks, tensorboard สำเร็จรูป

[!] ซ่อนรายละเอียด -- ปรับ custom training ยากกว่า

PyTorch = ขับรถเอง (ควบคุมทุกอย่าง) - Keras = นั่งแท็กซี่ (สะดวก แต่เลือกทางไม่ได้)

มองให้เห็นก่อนเทรน -- Step 1: วาดข้อมูล + เส้นแบ่งสุ่ม ๆ

ต้องเพิ่มไลบรารีวาดกราฟก่อน: uv add matplotlib

# viz.py — ดูว่าโมเดล "เดา" ได้แย่แค่ไหน
import matplotlib.pyplot as plt
import torch

torch.manual_seed(42)
N = 200
X = torch.randn(N, 2)
y = ((X[:, 0] * 1.5 + X[:, 1] - 0.5)
     > 0).float().unsqueeze(1)

model = torch.nn.Sequential(
    torch.nn.Linear(2, 1),
    torch.nn.Sigmoid(),
)                        # ยังไม่เทรนเลย!

Xp, yp = X.numpy(), y.numpy().ravel()
plt.scatter(Xp[yp == 1, 0], Xp[yp == 1, 1],
            c="tab:green", s=15)
plt.scatter(Xp[yp == 0, 0], Xp[yp == 0, 1],
            c="tab:red", s=15)
plt.show()

- เขียว = class 1, แดง = class 0 -- ข้อมูลแบ่งด้วยเส้นตรงได้

- ก่อนเทรน w, b เป็นค่าสุ่ม -> เส้นแบ่งวิ่งพาดผิดที่

[Tip] เป้าหมายของ training ในสายตา: หมุน + เลื่อนเส้นนี้ไปอยู่กึ่งกลางสองฝั่งให้ได้

Step 2: เส้นแบ่งมาจากไหน? -- จาก w, b ที่โมเดลถืออยู่

เส้นแบ่ง = จุดที่โมเดลเดาไม่ออกว่าฝั่งไหน คือ \(\sigma(z) = 0.5 \iff z = 0\)

\(w_1 x_1 + w_2 x_2 + b = 0 \;\Longrightarrow\; x_2 = -\dfrac{w_1 x_1 + b}{w_2}\)

def plot_boundary(model):
    w = model[0].weight.data.ravel()
    b = model[0].bias.data.item()
    xs = torch.linspace(-3, 3, 2)
    ys = -(w[0] * xs + b) / w[1]
    plt.plot(xs.numpy(), ys.numpy(),
             "k-", lw=2)

plot_boundary(model)   # ลองก่อนเทรน!
plt.show()

- ดึง weight, bias ออกจาก nn.Linear (ชั้นแรก = model[0])

- แทนจุด x1 สองค่า (-3, 3) -> คำนวณ x2 -> ได้เส้นตรง 2 จุด

- ฟังก์ชันนี้ reuse ได้ทุกตอนที่อยาก "ถ่ายรูป" โมเดล

Step 3: ดูโมเดลเรียนรู้ -- วาดใหม่ทุก 10 epoch

for epoch in range(200):
    y_hat = model(X)
    loss = loss_fn(y_hat, y)
    opt.zero_grad()
    loss.backward()
    opt.step()

    if epoch % 10 == 0:      # ทุก 10 epoch
        plt.clf()            # เคลียร์กราฟเดิม
        plt.scatter(Xp[yp == 1, 0],
                    Xp[yp == 1, 1], c="tab:green", s=15)
        plt.scatter(Xp[yp == 0, 0],
                    Xp[yp == 0, 1], c="tab:red", s=15)
        plot_boundary(model)
        plt.title(
            f"epoch {epoch} | loss {loss.item():.3f}")
        plt.pause(0.001)     # ให้หน้าต่าง "เคลื่อน" สด ๆ

[See] สิ่งที่จะเห็น: เส้นสุ่ม ๆ -> หมุน+เลื่อนทีละนิด -> นิ่งในตำแหน่งแบ่งสองคลาสสวยงาม (loss ลดลงพร้อมกัน)

- plt.pause(0.001) = บังคับ render ใหม่ในหน้าต่างเดิม

[Tip] อยากได้ GIF แนบรายงาน? เปลี่ยน pause เป็น plt.savefig(f"e{epoch}.png") แล้วต่อเฟรมด้วย imageio

Polling Dashboard -- Backend: train แล้วเปิด API ให้ JS ดึงสถานะ

# views.py
import threading, json, torch
from django.http import JsonResponse

_state = dict(epoch=0, loss=0, acc=0,
              w=[0.0, 0.0], b=0.0, running=False)

def _train():
    torch.manual_seed(42)
    X = torch.randn(200, 2)
    y = ((X[:,0]*1.5+X[:,1]-0.5)>0
         ).float().unsqueeze(1)
    model = torch.nn.Sequential(
        torch.nn.Linear(2,1), torch.nn.Sigmoid())
    loss_fn = torch.nn.BCELoss()
    opt = torch.optim.SGD(model.parameters(), lr=0.1)
    _state['running'] = True
    for ep in range(200):
        y_hat = model(X)
        loss = loss_fn(y_hat, y)
        opt.zero_grad(); loss.backward(); opt.step()
        _state.update(
            epoch=ep,
            loss=round(loss.item(),4),
            acc=round(((y_hat>0.5).float()==y
                ).float().mean().item(),4),
            w=[round(x,4) for x in
                model[0].weight.data.tolist()[0]],
            b=round(model[0].bias.data.item(),4))
    _state['running'] = False

def train_start(request):
    threading.Thread(target=_train, daemon=True).start()
    return JsonResponse({'ok': True})

def train_state(request):
    return JsonResponse(_state)

- _state = ตัวแปรกลางเก็บสถานะโมเดลล่าสุด -- JS จะดึงตัวนี้

- threading.Thread = train ในเบื้องหลัง ไม่บล็อก Django server

- /api/train/ = ปุ่มเริ่มเทรน (POST)

- /api/state/ = JS polling ทุก 50ms ดึง loss, acc, w, b

[!] ข้อเสีย: JS ต้องยิง request ซ้ำ -- เปลือง bandwidth, lag ตาม polling interval

Polling Dashboard -- Frontend: วาด boundary สด ๆ บน Canvas

<canvas id="cv" width="400" height="400"></canvas>
<div id="info"></div>
<script>
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const CX = 200, S = 60;

const pts = [];
function randn(){let u=0,v=0;while(!u)u=Math.random();
  v=Math.random();return Math.sqrt(-2*Math.log(u))*Math.cos(6.283*v)}
for(let i=0;i<200;i++){
  const x=randn(),y=randn();
  pts.push({x,y,l:(x*1.5+y-0.5)>0?1:0})}

function draw(s){
  ctx.fillStyle='#1a1a2e'; ctx.fillRect(0,0,400,400);
  for(const p of pts){
    ctx.beginPath();
    ctx.arc(CX+p.x*S, CX-p.y*S, 3, 0, 6.283);
    ctx.fillStyle=p.l?'#4ade80':'#f87171'; ctx.fill();}
  if(s.w && s.w[1]!==0){
    ctx.beginPath();
    ctx.moveTo(0,  CX-(-(s.w[0]*(-3)+s.b)/s.w[1])*S);
    ctx.lineTo(400,CX-(-(s.w[0]*( 3)+s.b)/s.w[1])*S);
    ctx.strokeStyle='#fbbf24'; ctx.lineWidth=2; ctx.stroke();}
  document.getElementById('info').textContent=
    `epoch ${s.epoch} | loss ${s.loss} | acc ${(s.acc*100).toFixed(0)}%`;}

// polling loop — ยิง request ซ้ำทุก 50ms
async function poll(){
  const r = await fetch('/api/state/');
  const s = await r.json();
  draw(s);
  if(s.running) setTimeout(poll, 50);}

fetch('/api/train/').then(()=>poll());
</script>

- Canvas = ผ้าใบวาดด้วย JS -- เร็วกว่า DOM manipulation มาก

- สูตรเส้นแบ่ง: y = -(w1x + b) / w2 แทนค่าจาก /api/state/

- setTimeout(poll, 50) = ดึงทุก 50ms -> กราฟลื่น ~20fps

[!] ทุก 50ms = วินาทีละ 20 request -- ถ้ามี 100 คนดู = 2000 request/วินาที!

Django Dashboard -- Backend: Stream สถานะโมเดลทุก epoch ด้วย SSE

# views.py
import json, torch
from django.http import StreamingHttpResponse

def train_stream(request):
    def generate():
        torch.manual_seed(42)
        X = torch.randn(200, 2)
        y = ((X[:,0]*1.5+X[:,1]-0.5)>0
             ).float().unsqueeze(1)
        model = torch.nn.Sequential(
            torch.nn.Linear(2,1), torch.nn.Sigmoid())
        loss_fn = torch.nn.BCELoss()
        opt = torch.optim.SGD(model.parameters(), lr=0.1)
        for ep in range(200):
            y_hat = model(X)
            loss = loss_fn(y_hat, y)
            opt.zero_grad(); loss.backward(); opt.step()
            yield "data: " + json.dumps(dict(
                epoch=ep,
                loss=round(loss.item(),4),
                acc=round(((y_hat>0.5).float()==y
                    ).float().mean().item(),4),
                w=[round(x,4) for x in
                    model[0].weight.data.tolist()[0]],
                b=round(model[0].bias.data.item(),4)
            )) + "\n\n"
    return StreamingHttpResponse(
        generate(),
        content_type='text/event-stream')

- StreamingHttpResponse = ส่งข้อมูลทีละชิ้นโดยไม่ปิด connection

- yield = สร้าง generator -- ส่ง JSON ทุก epoch แล้วรอ

- text/event-stream = รูปแบบ SSE (Server-Sent Events) ที่ browser รู้จัก

- ไม่ต้อง threading -- generator ทำงานใน request cycle ของ Django เอง

[Tip] SSE ต่างจาก polling: ไม่ต้องยิง request ซ้ำ -- server ส่งข้อมูลมาเองเมื่อพร้อม

Django Dashboard -- Frontend: รับ stream แล้ววาด boundary สด ๆ

<canvas id="cv" width="400" height="400"></canvas>
<div id="info"></div>
<script>
const cv = document.getElementById('cv');
const ctx = cv.getContext('2d');
const CX = 200, S = 60;

// ข้อมูล (seed เดียวกับ backend)
const pts = [];
function randn(){let u=0,v=0;while(!u)u=Math.random();
  v=Math.random();return Math.sqrt(-2*Math.log(u))*Math.cos(6.283*v)}
for(let i=0;i<200;i++){
  const x=randn(),y=randn();
  pts.push({x,y,l:(x*1.5+y-0.5)>0?1:0})}

function draw(s){
  ctx.fillStyle='#1a1a2e'; ctx.fillRect(0,0,400,400);
  for(const p of pts){
    ctx.beginPath();
    ctx.arc(CX+p.x*S, CX-p.y*S, 3, 0, 6.283);
    ctx.fillStyle=p.l?'#4ade80':'#f87171'; ctx.fill();}
  if(s.w && s.w[1]!==0){
    ctx.beginPath();
    ctx.moveTo(0,  CX-(-(s.w[0]*(-3)+s.b)/s.w[1])*S);
    ctx.lineTo(400,CX-(-(s.w[0]*( 3)+s.b)/s.w[1])*S);
    ctx.strokeStyle='#fbbf24'; ctx.lineWidth=2; ctx.stroke();}
  document.getElementById('info').textContent=
    `epoch ${s.epoch} | loss ${s.loss} | acc ${(s.acc*100).toFixed(0)}%`;}

// EventSource = browser API สำหรับรับ SSE stream
const es = new EventSource('/api/train/');
es.onmessage = (e) => draw(JSON.parse(e.data));
es.onerror   = () => es.close();  // จบ stream → ปิด
</script>

- EventSource = browser API สำหรับรับ SSE -- ไม่ต้องเขียน loop เอง

- onmessage = callback ที่เรียกทุกครั้งที่ server ส่ง data: ... มา

- สูตรเส้นแบ่ง: y = -(w1x + b) / w2 แทนค่าจาก stream ตรง ๆ

- ไม่ต้อง setTimeout -- browser จัดการ timing ให้เอง

[Tip] ลองเปิด /dashboard/ บน 2 เบราว์เซอร์พร้อมกัน -- เห็น boundary ขยับพร้อมกัน realtime!

รันเลย: uv run train.py

$ uv run train.py
epoch  40 | loss 0.3940 | acc 94.50%
epoch  80 | loss 0.3074 | acc 97.50%
epoch 120 | loss 0.2647 | acc 100.00%
epoch 160 | loss 0.2379 | acc 99.50%
epoch 200 | loss 0.2190 | acc 99.00%
w = tensor([[2.1772, 1.4041]])
b = tensor([-0.7237])

[-] loss ลด 0.39 -> 0.22 และ [+] accuracy ขึ้น 94% -> 99% -- โมเดลเรียนรู้จริง

[>] สายตรงจริงคือ (1.5, 1.0), b=-0.5 -- โมเดลเรียนได้ (2.18, 1.40), b=-0.72 ซึ่งเป็นเส้นเดียวกัน (scale ต่างกันเท่านั้น)

[Done] คุณเพิ่งฝึก neural network ตัวแรกด้วย PyTorch สำเร็จ!

[See] รันพร้อมโค้ดจาก Step 1-3 จะเห็นหน้าต่างกราฟวาดเส้นแบ่งใหม่ทุก 10 epoch จนนิ่ง

ข้อมูล XOR และเหตุผลที่เส้นตรงเดียวทำไม่ได้

def make_xor_data(n=200):
    """XOR: ช่อง 1,3 = 1, ช่อง 2,4 = 0
    → เส้นตรงเดียวแบ่งไม่ได้แน่นอน"""
    X = torch.rand(n, 2) * 4 - 2  # [-2, 2]
    y = (X[:, 0] * X[:, 1] > 0).float()
    return X, y.unsqueeze(1)

X_xor, y_xor = make_xor_data()

# scatter plot: สีเขียว=1, แดง=0
# จุดในเขตรวมสีเดียวกัน = XOR = ทแยงกัน
# → เส้นตรงเส้นเดียวไม่มีวันแบ่งถูกทั้งหมด

- สูตร x1 * x2 > 0 หมายถึงจุดอยู่ในช่องที่ 1 หรือ 3 (ค่าเท่ากัน)

- ถ้าวาด scatter จุดสีแดงจะอยู่ช่อง 2,4 และเขียวช่อง 1,3 -> " checkerboard "

- เส้นตรงที่ลากตัด ctxt ได้ดีสุด = 50% แบ่งไม่ได้มากกว่านั้น -- Minsky & Papert 1969 ใช้ XOR เป็นหลักฐานว่าเพอร์เซปตรอนมีขีดจำกัด

[Tip] วิธีพลิก: hidden layer พับพื้นที่ให้ XOR กลายเป็น linearly separable ได้ คือหัวใจของ "deep learning"

สรุป + การบ้าน

สิ่งที่ทำวันนี้

  • uv: install -> init -> add -> run (workflow เดียวใช้ทุกโปรเจกต์)
  • นิวรอน = z = w*x + b ตามด้วย activation
  • มองการเทรนเป็นภาพ: เส้นแบ่ง x2 = -(w1x1+b)/w2 หมุน+เลื่อนจน loss ต่ำ
  • BCELoss + SGD + autograd = training loop

การบ้าน (ส่งสัปดาห์หน้า)

  • เปลี่ยน lr เป็น 0.001 และ 1.0 -- เกิดอะไรขึ้?
  • ลอง dataset XOR (เส้นตรงเดียวแบ่งไม่ได้) -- เพอร์เซปตรอน fail อย่างไร?
  • เพิ่ม hidden layer: Linear(2,4) -> ReLU -> Linear(4,1) แก้ XOR ได้?

สัปดาห์หน้า: Dataset/DataLoader และ classification จริงด้วย PyTorch