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

OOP & Design Patterns
ใน Scikit-learn

วิเคราะห์สถาปัตยกรรม Machine Learning Library ระดับโลกผ่านเลนส์ OOP

🎯 เป้าหมาย: เข้าใจโครงสร้าง BaseEstimator, Mixins และการใช้ SOLID Principles ในการออกแบบ ML Pipeline

ทำไมต้องศึกษา Scikit-learn?

ความสม่ำเสมอ (Consistency)

ไม่ว่าจะเป็น Linear Regression, SVM หรือ Random Forest ทุกอย่างมีวิธีเรียกใช้เหมือนกัน:

model.fit(X, y)
model.predict(X)

พลังของ OOP

  • Inheritance: ใช้ Base Class ร่วมกัน
  • Polymorphism: สลับเปลี่ยนโมเดลได้ทันทีโดยไม่ต้องแก้โค้ดส่วนอื่น
  • Composition: สร้าง Pipeline ที่ซับซ้อนจากชิ้นส่วนย่อย

3 เสาหลักของ Scikit-learn Objects

1. Estimator

วัตถุที่เรียนรู้จากข้อมูล

estimator.fit(data)
2. Transformer

วัตถุที่แปลงข้อมูล (Data Processing)

transformer.transform(data)
3. Predictor

วัตถุที่ทำนายผลลัพธ์

predictor.predict(data)

BaseEstimator: แม่ของทุกสรรพสิ่ง

คลาสแม่แบบ (Parent Class) ที่ทุกโมเดลใน Scikit-learn สืบทอดมา [1]

from sklearn.base import BaseEstimator

class MyModel(BaseEstimator):
    def __init__(self, param1=1):
        self.param1 = param1  # เก็บค่าพารามิเตอร์
สิ่งที่ได้จากการสืบทอด:
  • get_params(): ดึงค่าพารามิเตอร์ทั้งหมด (ใช้ตอนจูนโมเดล)
  • set_params(): ตั้งค่าพารามิเตอร์ใหม่

การใช้ Mixins

Scikit-learn ใช้ Multiple Inheritance เพื่อแยกความสามารถ (ISP Principle)

ClassifierMixin

สำหรับโมเดลจำแนกประเภท (Classification)

  • บังคับใช้เมธอด score() ที่คืนค่า Accuracy

TransformerMixin

สำหรับตัวแปลงข้อมูล

  • เพิ่มเมธอด fit_transform() ให้อัตโนมัติ
class LogisticRegression(BaseEstimator, LinearClassifierMixin, SparseCoefMixin): ...

SOLID: SRP ใน Scikit-learn

"แยกการเตรียมข้อมูล (Preprocessing) ออกจากตัวโมเดล (Model)"

❌ แบบรวมศูนย์ (God Class)

เขียนโค้ด Scaling ข้อมูล, เติมค่าว่าง, และเทรนโมเดล รวมอยู่ในคลาสเดียว

✅ แบบ Scikit-learn
  • Imputer: รับผิดชอบแค่เติมค่าว่าง
  • StandardScaler: รับผิดชอบแค่ปรับสเกล
  • SVC: รับผิดชอบแค่หา Hyperplane

SOLID: OCP

"เปิดรับการขยาย (Extension) แต่ปิดกั้นการแก้ไข (Modification)"

Scikit-learn อนุญาตให้คุณสร้าง Custom Estimator ได้เอง

คุณสามารถสร้างอัลกอริทึมใหม่ หรือวิธีการแปลงข้อมูลแบบใหม่ โดยการสืบทอดจาก BaseEstimator แล้วนำไปใช้ร่วมกับฟังก์ชันมาตรฐานอย่าง GridSearchCV หรือ Pipeline ได้ทันที โดยไม่ต้องแก้โค้ดของ Library [1]

class OutlierRemover(BaseEstimator, TransformerMixin):
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        # Logic การตัด Outlier ของเราเอง
        return X[X < 100]

SOLID: LSP

"ความสามารถในการแทนที่กันได้ (Interchangeability)"

Polymorphism ในการกระทำ

เนื่องจากทุก Estimator มีเมธอด fit() และ predict() เหมือนกัน เราจึงสามารถเขียนโค้ดตรวจสอบผลลัพธ์ (Evaluation Code) เพียงชุดเดียว แล้วส่งโมเดลใดก็ได้เข้าไป

def evaluate_model(model, X, y):
    model.fit(X, y)
    print(model.score(X, y))

# ส่งอะไรไปก็ได้
evaluate_model(SVC(), X, y)
evaluate_model(RandomForestClassifier(), X, y)

SOLID: DIP

Pipeline ไม่ขึ้นกับ Concrete Class

Pipeline ของ Scikit-learn ไม่ได้ผูกติดกับคลาส StandardScaler หรือ SVC โดยตรง แต่ผูกติดกับ Abstraction (วัตถุที่มีเมธอด fit/transform)

pipeline = Pipeline([('scaler', AnyTransformer), ('model', AnyEstimator)])

Design Pattern: Composite

การมองกลุ่มของวัตถุ ให้เป็นวัตถุเดียว (Treating a group of objects as a single object)

Pipeline คือตัวอย่างที่ชัดเจนที่สุด มันเก็บ List ของ Estimators ไว้ข้างใน

เมื่อเราเรียก pipeline.fit() มันจะวนลูปเรียก fit_transform() ของทุกตัว แล้วส่งผลลัพธ์ต่อไปเป็นทอดๆ

pipe = Pipeline([
    ('scaler', StandardScaler()),
    ('pca', PCA()),
    ('svc', SVC())
])

# เรียกครั้งเดียว ทำงานทั้ง 3 ขั้นตอน
pipe.fit(X, y)

Design Pattern: Strategy

การสลับเปลี่ยนอัลกอริทึมภายใน Object

ใน Scikit-learn เราเห็นสิ่งนี้ผ่าน Hyperparameters ที่เปลี่ยนพฤติกรรมของโมเดล

# เปลี่ยน Strategy ของ Kernel
model_linear = SVC(kernel='linear')
model_rbf = SVC(kernel='rbf')

# ภายในคลาส SVC จะมี Logic เพื่อเลือกใช้สมการคณิตศาสตร์ที่ต่างกันตาม Strategy ที่เลือก [2]

Design Pattern: Template Method

Base Class กำหนดโครงสร้าง แต่ให้ Subclass เติมเต็มส่วนที่ขาด

ใน BaseEstimator:

มีการเตรียมเมธอด get_params() และ set_params() ไว้ให้แล้ว โดยอาศัยการ Introspect (ตรวจสอบ) __init__ arguments [1]

ใน Custom Estimator:

เราแค่เขียน __init__ ให้ถูกต้องตามกฎ Scikit-learn ก็จะได้ฟีเจอร์เหล่านั้นไปใช้ทันที

กฎเหล็กของ __init__

เพื่อให้ OOP ของ Scikit-learn (เช่นการโคลนโมเดล) ทำงานได้ ต้องปฏิบัติตามนี้ [3]:

ห้ามทำ ❌

  • ห้ามใส่ Logic หรือ Validation ใน __init__
  • ห้ามเปลี่ยนชื่อพารามิเตอร์ (Input arg ต้องตรงกับ Self attribute)
  • ห้ามรับ *args หรือ **kwargs

สิ่งที่ควรทำ ✅

def __init__(self, param1=1):
    self.param1 = param1
        

รับค่าแล้วเก็บใส่ตัวแปรชื่อเดียวกันทันที

Duck Typing คือหัวใจ

"ถ้ามันเดินเหมือนเป็ด และร้องเหมือนเป็ด มันก็คือเป็ด"

Scikit-learn ไม่ได้เช็ค Type อย่างเข้มงวดตลอดเวลา (เช่น isinstance(obj, Classifier)) แต่เช็คว่าวัตถุนั้นมีเมธอดที่ต้องการหรือไม่ [4]

  • ถ้ามี transform() ถือว่าเป็น Transformer
  • ถ้ามี predict() ถือว่าเป็น Estimator/Predictor

การสร้าง Custom Transformer

เราจะสร้างคลาสที่สืบทอดจาก BaseEstimator และ TransformerMixin

TransformerMixin จะแถมเมธอด fit_transform() มาให้ฟรี ซึ่งมันก็คือการเรียก fit() แล้วตามด้วย transform() นั่นเอง

from sklearn.base import BaseEstimator, TransformerMixin

class MyScaler(BaseEstimator, TransformerMixin):
    def __init__(self, factor=1):
        self.factor = factor
        
    def fit(self, X, y=None):
        # เรียนรู้ค่า mean, etc (ถ้ามี)
        return self
        
    def transform(self, X):
        return X * self.factor

จัดการข้อมูลที่ซับซ้อนด้วย ColumnTransformer

ในโลกความเป็นจริง ข้อมูลมักมีทั้งตัวเลข (Numerical) และตัวหนังสือ (Categorical)

ColumnTransformer ช่วยให้เราแยก (Split) การประมวลผลตามคอลัมน์ แล้วรวม (Combine) กลับมาใหม่

Numeric Cols -> Scaler
Categorical Cols -> OneHot

⬇️ รวมร่าง ⬇️

Model Input Vector

การบันทึกวัตถุ (Persistence)

OOP ช่วยให้เราบันทึกสถานะของ Object (Model ที่เทรนแล้ว) ลงไฟล์ได้

Pickle / Joblib

Python ใช้กลไก Serialization เพื่อแปลง Object ใน Memory ให้เป็น Binary File

joblib.dump(model, 'model.pkl')

Production Use

เราสามารถโหลดไฟล์นี้กลับมาเป็น Object พร้อมใช้งานได้ทันที (State ของ self.coef_ ถูกบันทึกไว้ครบ)

model = joblib.load('model.pkl')

สรุปหลักการก่อนเข้า Lab

Inheritance: สืบทอด BaseEstimator เพื่อฟีเจอร์มาตรฐาน
Mixins: ใช้ TransformerMixin เพื่อ fit_transform
Encapsulation: เก็บ Logic และ Parameters ใน Class
Composition: ใช้ Pipeline เชื่อมต่อ Object หลายตัว

เข้าสู่ ห้องปฏิบัติการ

สร้าง Custom Transformer และ Pipeline สำหรับข้อมูล Iris Dataset

ภารกิจ (Mission)

เราจะสร้าง Class OutlierRemover เพื่อตัดข้อมูลที่ผิดปกติ แล้วนำไปต่อกับ SVM เพื่อจำแนกพันธุ์ดอกไม้

Step 1: เตรียม Environment

ติดตั้ง Library ที่จำเป็น

pip install scikit-learn pandas numpy matplotlib

หรือกรณีที่ใช้ uv

uv add scikit-learn pandas numpy matplotlib

สร้างไฟล์ lab_sklearn_oop.py แล้ว import:

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, TransformerMixin
from sklearn.pipeline import Pipeline
from sklearn.svm import SVC
from sklearn.datasets import load_iris
from sklearn.model_selection import train_test_split
      

Step 2: โหลดข้อมูล (The Iris Dataset)

ใช้ Dataset มาตรฐาน Iris (ดอกไม้ 3 สายพันธุ์)

  • Features: Sepal Length, Sepal Width, Petal Length, Petal Width
  • Target: Setosa, Versicolor, Virginica
# โหลดข้อมูล
iris = load_iris()
X = iris.data
y = iris.target

# แบ่ง Train/Test (80/20)
X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

Step 3: โครงสร้าง Custom Transformer

สร้างคลาส OutlierClipper ที่สืบทอดจาก BaseEstimator และ TransformerMixin

class OutlierClipper(BaseEstimator, TransformerMixin):
    def __init__(self, factor=1.5):
        self.factor = factor  # ตัวคูณ IQR
        
    def fit(self, X, y=None):
        # คำนวณขอบเขตล่างและบน (Lower/Upper Bound)
        # เดี๋ยวเราจะเติมโค้ดตรงนี้
        return self
        
    def transform(self, X):
        # แทนที่ค่าที่เกินขอบเขต
        # เดี๋ยวเราจะเติมโค้ดตรงนี้
        return X_clipped

Step 4: Implement fit()

ในเมธอด fit เราจะเรียนรู้จากข้อมูล (X) ว่าขอบเขตของข้อมูลควรเป็นเท่าไหร่ (ใช้ IQR Technique)

    def fit(self, X, y=None):
        # X เป็น numpy array
        # คำนวณ Quartile 1 และ 3
        Q1 = np.percentile(X, 25, axis=0)
        Q3 = np.percentile(X, 75, axis=0)
        IQR = Q3 - Q1
        
        # บันทึก State ลงใน Object (Attributes มี _ ต่อท้ายตามธรรมเนียม)
        self.lower_bound_ = Q1 - (self.factor * IQR)
        self.upper_bound_ = Q3 + (self.factor * IQR)
        
        return self  # ต้องคืนค่า self เสมอเพื่อให้ Chain ได้

Step 5: Implement transform()

ใช้ค่า lower_bound_ และ upper_bound_ ที่เรียนรู้มา เพื่อจำกัดค่า (Clip) ข้อมูล

    def transform(self, X):
        # ตรวจสอบว่า fit หรือยัง? (Optional แต่ดี)
        if not hasattr(self, "lower_bound_"):
            raise RuntimeError("This OutlierClipper instance is not fitted yet.")
            
        # ใช้ np.clip เพื่อจำกัดค่าให้อยู่ในช่วง
        return np.clip(X, self.lower_bound_, self.upper_bound_)

Step 6: สร้าง Pipeline

เชื่อมต่อ Custom Transformer ของเราเข้ากับ SVM Classifier

# สร้าง Pipeline: (Preprocessing) -> (Model)
pipeline = Pipeline([
    ('clipper', OutlierClipper(factor=1.5)),
    ('svm', SVC(kernel='linear'))
])

# สั่งเทรนทีเดียว (จะเรียก clipper.fit -> clipper.transform -> svm.fit)
pipeline.fit(X_train, y_train)

print("Training Complete!")

Step 7: ประเมินผล (Evaluation)

ทดสอบ Pipeline กับ Test Set

# ทำนายผล
# (เรียก clipper.transform -> svm.predict)
y_pred = pipeline.predict(X_test)

# ตรวจสอบความแม่นยำ
score = pipeline.score(X_test, y_test)
print(f"Accuracy: {score:.2f}")

ทำไมถึงดี?

เราไม่ต้องเขียนโค้ดเพื่อ Transform X_test แยกต่างหาก Pipeline จัดการให้เราเอง ทำให้ลดความเสี่ยงเรื่อง Data Leakage

Step 8: เปลี่ยนโมเดล (LSP Test)

ลองเปลี่ยนจาก SVC เป็น RandomForestClassifier โดยไม่แก้ Pipeline Logic

from sklearn.ensemble import RandomForestClassifier

# เปลี่ยนแค่ตัวสุดท้ายของ Pipeline
pipeline.steps.pop()  # เอา SVM ออก
pipeline.steps.append(('rf', RandomForestClassifier()))

# Fit ใหม่ได้เลย
pipeline.fit(X_train, y_train)
print(f"New Accuracy: {pipeline.score(X_test, y_test):.2f}")
*นี่คือพลังของ Liskov Substitution Principle: เราสามารถสลับ Subclass ของ BaseEstimator ได้อย่างอิสระ*

Step 9: การเข้าถึงพารามิเตอร์

เนื่องจากเราสืบทอด BaseEstimator เราจึงใช้ get_params() ได้ทันที

# ดูพารามิเตอร์ทั้งหมดใน Pipeline
params = pipeline.get_params()
print(params)

# เราจะเห็น 'clipper__factor' อยู่ในนั้น
# ลองเปลี่ยนค่า factor ผ่าน Pipeline
pipeline.set_params(clipper__factor=2.0)
pipeline.fit(X_train, y_train)  # เทรนใหม่ด้วย factor ใหม่

Lab Completed!

"คุณได้สร้าง OOP Component ที่ทำงานร่วมกับ Standard Library ระดับโลกได้"

Key Takeaways

  • fit() เพื่อเรียนรู้, transform() เพื่อแปลง
  • BaseEstimator ให้ฟีเจอร์พื้นฐาน
  • Mixin ให้ Interface มาตรฐาน

Next Steps

  • ลองใช้ GridSearch เพื่อหาค่า factor ที่ดีที่สุด
  • ลองสร้าง Custom Classifier
  • นำไป Deploy ด้วย FastAPI [6, 7]