Tutorial

Common Mistakes อันน่ากำหมัดเมื่อเจอ Code สุดเทพ!

By Arnon Puitrakul - 18 กุมภาพันธ์ 2021 - 2 min read min(s)

Common Mistakes อันน่ากำหมัดเมื่อเจอ Code สุดเทพ!

เรื่องของเรื่องมันมีอยู่ว่า ช่วงนี้เราก็เข้ามาเรียน ป.โท ที่มันก็ต้องใช้การเขียนโปรแกรมอยู่เยอะ เวลาเราไปอ่าน Code ของสายวิทย์บางตัว ก็คือ ง้างหมัดรอแล้ว เขียนอะไรกันนนนนน ถามว่าโปรแกรมมันทำงานได้มั้ย ทำได้นะ แต่เวลาเราจะมา Maintain ต่อก็คือ บายจ๊ะ พันกันเละเลยจ๊ะ เอาจริง ๆ เราก็เข้าใจนะว่า ส่วนใหญ่อาจจะไม่ได้มีชั่วโมงบินสูงเท่ากับคนที่เขียนมานาน ทำให้วันนี้เราเลยจะมาเอา Common Mistakes มาพร้อมกับ แชร์วิธีง่าย ๆ ในการจัดการ Source Code ว่าเรามีวิธีการจัดการมันอย่างไร แต่ต้องบอกว่า วิธีของเราอาจจะไม่ได้ดีที่สุดสำหรับทุกงาน แต่มันเหมาะกับงานเราเท่านั้น ดังนั้น ถ้าจะเอาไปใช้ต่อ ก็อาจจะมีการปรับเปลี่ยนให้เข้ากับงานของเรา

Goal ของเราคือ Code ที่เราเขียนทั้งหมดจะต้อง Maintain ต่อในอนาคตได้ มีการเขียน และจัดการส่วนต่าง ๆ อย่างเป็นระบบเรียบร้อย และต้องสามารถลดความผิดพลาดที่อาจจะเกิดขึ้นในการเขียนได้ด้วย

Naming Convention

a = input()
b = input()
c = a * b * a

ขอยกให้เรื่อง Naming Convention เป็นเรื่องแรกเลย เพราะทำให้เรากำหมัดมาอย่างยาวนาน หัวร้อนจนจะบ้า เพราะเคยต้องมานั่งแกะ Code ที่เขียนชื่อตัวแปรมาเป็น a,b,c แล้วยัดสมการอะไรไม่รู้ลงไป มึนไปเลย จอบอ KO ยอมแพ้ในพริบตา และเริ่มเขียนใหม่เองทันที

อันที่พีค ๆ สุด ๆ เลยคือ เจอการใช้พวก Camel Case ผสมกับ Snake Case โดยที่เป็นชื่อตัวแปรธรรมดาเหมือนกันเลย แล้วไม่ได้มีการ Note อะไรไว้ใน Document ด้วยนะว่า ทำไมมันต่าง ทำให้เวลาเราเรียก เราก็ต้องมานั่งหา

เวลาเราตั้งชื่อต่าง ๆ เราควรที่จะมีสิ่งที่เรียกว่า Naming Convention ง่าย ๆ คือ เราต้องมาตกลงกันว่า เวลาเราจะตั้งชื่ออะไรก็ตาม เราจะตั้งมันยังไง เพื่อให้ทั้งโปรแกรม มันเป็นไปในทางเดียวกัน เวลาเรา หรือคนในทีมมาอ่าน ทุกคนก็จะเข้าใจในทิศทางที่ตรงกันว่า นี่เรากำลังเขียนอะไรกันแน่ กับเวลาเราจะเรียก มันก็ลดเวลาที่เราต้องไปไล่เปิดดูอีกว่า อันนั้นมันใช้ชื่ออะไรนะ ไปได้เยอะมาก ๆ

ตัวอย่างเช่น เราอาจจะตกลงกันเลยว่า ชื่อตัวแปรเราจะใช้ Snake Case นะ ส่วนชื่อ Class เราจะใช้ Camel Case นะ พร้อมกับ ในชื่อมันต้องเรียงอะไรยังไงก็ว่ากันไป มันก็ทำให้เราทำงานได้ง่ายขึ้น เร็วขึ้นเยอะมาก

Splitting Code

อีกปัญหาที่เจอแล้วอยากจะง้างหมัดพอ ๆ กันคือ มันมีการ Duplicate Code กันเยอะมาก ก็คือ ในไฟล์นี้ เราอยากใช้ Code ตัวเดียวกับที่อยู่ในอีกไฟล์ด้วย ก็ Copy Code อันที่เขียนแล้วนั่นแหละ ไปใส่ในไฟล์ใหม่ เจอครั้งแรกก็คือ อุทานว่า "เชี้ยยยยยย" อย่างรุนแรง สำหรับคนที่อาจจะยังไม่เข้าใจว่ามันชิบหายยังไงลองคิดตาม

สมมุติว่า เราเขียน Code สำหรับการอ่าน Input ข้อมูลจากไฟล์เข้ามา ซึ่งมันเป็น Code ชุดที่เราอาจจะใช้ในหลาย ๆ ส่วนของ Code ที่เราเขียน เช่นการทดลอง A และ B เราจะต้องอ่านไฟล์เดียวกันนี่แหละ ซึ่งมันก็ต้องทำงานเหมือนกันแน่นอน ถ้ามันเหมือนกัน เราก็ Copy มันมาลงในไฟล์ใหม่เลยละกันง่าย ๆ พอเราทำแบบนี้ไปเรื่อย ๆ วันนึงเรามาเจอว่าส่วนนึงเราอาจจะเขียนผิด เช่น อาจจะลืมบวกเลขอะไรก็ว่ากันไป ตอนแก้นี่แหละเรื่องใหญ่ เพราะเราต้องไปไล่ดูอีกว่า ในงานของเราทั้งหมด มีส่วนไหนบ้างที่เราก๊อปไปแปะ แล้วก็ต้องไล่แก้ไปทุก ๆ ไฟล์ ก็ใช้เวลาเยอะอี๊ก เรื่องใหญ่เลยละ

def read_fastq (fastq_path: Path) -> list :
	# Read FASTQ File
    return fastq_records

def read_csv (csv_path: Path) -> list :
	# Read CSV File
    return csv_records
import_dataset.py

วิธีการแก้ปัญหาคือ เราจะต้องทำการ Split Code ของเราออกมาให้ได้มากที่สุด พยายามเขียนออกมาให้เป็น Modular มากเข้าไว้ อาจจะจัดเป็นไฟล์ Function ตัวนึงสำหรับการทำอะไรบางอย่าง เช่น อาจจะเขียนเป็น import_dataset เป็นไฟล์ที่รวม Function สำหรับการ Import ข้อมูลจากแบบต่าง ๆ เข้ามา

from dataset_import import read_fastq

fastq_record = read_fastq(path)

และเวลาเราใช้ เราก็ Import เข้ามา และเรียกใช้งานได้เลย ทำให้ไม่ว่าเราจะใช้ Code ชุดนี้กี่ครั้ง เราก็ไม่จำเป็นต้อง Copy หิ้วหวีไปหิ้วหวีมาแล้ว ผิดก็แก้ที่เดียวเลย แต่ต้องระมัดระวังพวกข้อมูลที่เข้าออกว่า เราจะต้องกำหนด Format และ Type ของข้อมูลที่เราใส่เข้าไป และ Return กลับไป ถ้ามันไม่เหมือนกัน หรือไม่มีทางเหมือนกันเลย เราอาจจะเขียนตัว Function ในการแปลงออกมาอีกรอบก็ได้เหมือนกัน

Core ของข้อนี้คือ เราต้องพยายามที่จะเขียนให้มันแยกชิ้นให้ได้มากที่สุด และ ทำให้เราสามารถ Reuse Code ที่เราเขียนได้มากที่สุดนั่นเอง ข้อดีคือ ถ้ามีปัญหาเราก็แก้ที่เดียว และ ลดเวลาในการเขียน กับ Maintain ได้เป็นจำนวนมากด้วย

Class & Object ช่วยได้เยอะ

อันนี้เป็นเทคนิคที่ได้ลองใช้แล้วรู้สึกว่า เห้ยมันดีมาก มันก็เป็นการกึ่ง ๆ Split Code กับจัดระเบียบ Code นิดหน่อย เกริ่นก่อนว่า ในงานของเรา มันจะมีการทดลองในหลาย ๆ Model ซึ่งปกติแล้วการทำ Model ไส้จริง ๆ แล้วมันจะมีขั้นตอนที่คล้าย ๆ กัน เช่นการ Load Data, Train Model และ Evaluate Model ยังไง ๆ เราทำ Model เราก็จะมีขั้นตอนพวกนี้เหมือนกันหมดแน่ ๆ

class ExperimentBuilder :
	def __init__ (self, experiment_name) :
    	self.experiment_name = experiment_name
    
    def load_data (self, data_path: Path) -> None :
    	# Load Data into Object
    
    def train_model (self) -> None :
    	# Train & Fit Model
    
    def evaluate_model (self) -> (int, float) :
    	# Evaluate Model
        return no_correct_answer, accuracy
    	

เราเลยคิดใหม่ เพื่อให้การตั้งชื่อ และมัน Maintain ง่ายขึ้น เราเขียน การทดลองออกมาเป็น Abstract Class ก่อน เพื่อให้เราเห็นภาพว่า ถ้าเราจะสร้าง Experiment ขึ้นมา เราจะต้อง Overload Method ตัวไหนบ้างอะไรบ้าง แล้วในแต่ละ Experiment จะมี Method ที่อาจจะต้องเพิ่มมา เป็น Utility หรือ Helper Method อะไรก็ว่ากันไป

หรือใน Helper อันไหนที่มันใช้ในหลาย ๆ ที่เราก็อาจจะเขียนแยกไปในไฟล์ปกติไปก็ได้ แล้วเราก็ Import มันเข้ามาใน Class ก็ได้เหมือนกัน

def run (self) -> (int, float) :
    # Run whole pipeline
    self.train_only(self.__model_path)
    self.predict_only()
    no_correct_answer, accuracy = self.evaluate_model()
    return no_correct_answer, accuracy

def predict_only (self, model_path : Path) -> (int,float) :
    # Predict from existing model

def train_only (self) -> None:
	# Train and evaluate model only

Abstract Method อีกแก๊งค์ที่เราว่าควรจะมีคือพวก Pipeline ต่าง ๆ อันนี้ช่วยให้เราจัดการเวลาเรารัน Batch Experiment ได้ดีมาก ๆ เช่นเราบอกว่า เรามี Model สัก 20 แบบ มีการ Vary Configuration สัก 2-3 ชุดต่อ Model แปลว่า เราจะมีอย่างมาก 60 Model ที่เราต้อง Train ถ้าเราบอกว่า Model แต่ละแบบ มันมีคำสั่งในการสั่ง Train หรือการทำอะไรบางอย่างไม่เหมือนกัน เราก็จะต้องสั่งทีละ Model หลายอย่างมาก

whole_experiment_sets = model_a + model_b

for experiment in whole_experiment_sets :
	experiment.run()

ถ้าเรา Inherit Abstract Class ออกมา เวลาเราจะสั่ง Run Experiment เราก็ไม่ต้องมานั่งคิดแล้วว่า Experiment แบบไหน ใช้คำสั่งอะไรในการสั่ง เราก็สามารถ Loop รอบเดียวได้เลย ไม่ต้องมานั่งหา นั่งเสียเวลาไปหมด ยากในการ Maintain อีก สารพัดอย่างไปหมด

from ExperimentBuilder import ExperimentBuilder

class ModelAExperimentBuilder (ExperimentBuilder) :
	# Implement Abstract Methods

แล้วเวลาเราจะสร้าง Experiment เช่นเป็น Model A ขึ้นมา เราก็ Inherit มาจาก ExperimentBuilder ไปได้เลย แล้วเราก็ Implement พวก Abstract Method ออกมาได้เลย เหมือนกับ Interface ใน Java แต่ใน Python มันไม่มีก็ใช้ตามนี้ไปแหละ และเวลาเราจะเอาอะไรมา Vary เราก็สร้าง Experiment เป็น Object จาก Class ที่เรา Inherit มาได้เลย แล้วเราก็อาจจะเขียนให้มันรับ Configuration เข้าไปก็ได้ เพื่อความ Safe เราก็อาจจะเขียนให้มี Default Configuration อันไหนเราใช้ Default ก็ปล่อยไป เขียนแค่ตัวที่ต้องการเปลี่ยนพอ

Experiment Name

อันนี้ก็มาจากปัญหาส่วนตัวเหมือนกัน เวลาเราทำมันหลาย ๆ อัน เราจะเจอปัญหาว่า ชิบหาย อันไหนคืออะไรฟร๊ะ เราเลยทำการตั้งชื่อที่มัน Systematics ขึ้นมา ซึ่งหลาย ๆ คนก็น่าจะทำกันแหละ เช่น ModelA_B128_10000 ก็คือเป็น Model A ที่ใช้ Batch Size เป็น 128 และใช้ Data ทั้งหมด 10,000 Record อะไรแบบนั้น

แต่ปัญหาที่เจอจากการใช้พวกนี้คือ เวลาเราทำเยอะ ๆ เข้า บางที มึนจ๊ะ มึนตึบ ลืมเปลี่ยนชื่อบ้าง หรือตอนเปลี่ยนชื่อ บางทีลืมเอ๊ะอะไรมาก่อนอะไรมาหลัง งงไปหมด ก็ต้องย้อนไปดูซึ่งมันเสียเวลามาก ๆ เราเลยเลือกที่จะเขียน Method สำหรับ Generate ชื่อไปเลย น่าจะทำให้ลดความผิดพลาดได้เยอะมาก ๆ

class Configuration :
    def __init__ (self, experiment_prefix: str, config_a: str, config_b: int) :
    	self.config_a = config_a
        self.config_b = config_b
        self.experiment_name = self.__generate_experiment_name
        
    def __generate_experiment_name (self) -> str :
        return experiment_name
    

ปกติเราจะเขียนเป็นอีก Class ขึ้นมาเลย เพื่อเก็บ Configuration ออกมาเลย แล้วในนั้นเราจะมี Method ในการ Generate ชื่อการทดลองออกมาไปเลย

Version Control is IMPORTANT!

เมื่อเราใช้วิธีการ Split Code ต่าง ๆ ที่เราเล่าไปแล้ว ปัญหานึงมันจะเกิดขึ้นมาทันที เมื่อเรามีการแก้ไข Method หรือ Function เช่น การเปลี่ยน Data Input หรือ Output ซึ่งลักษณะของข้อมูล หรืออะไรบางอย่างมันต่างไปจากเดิมทำให้ Method หรือ Code ส่วนอื่น ๆ ที่เรียกใช้ไม่สามารถใช้งานได้ พวกนี้เราเรียกว่า Breaking Change มันเป็นสิ่งที่เราไม่อยากให้เกิดขึ้นเท่าไหร่ ซึ่งมันก็มีวิธีในการหลีกเลี่ยงอยู่หลายวิธีด้วยกัน แต่ถ้าเกิดมันไม่ได้แล้วจริง ๆ เราจะต้องมั่นใจว่า เมื่อเราแก้ส่วนที่มีปัญหาไปแล้ว ส่วนที่มันผูกอยู่ด้วย มันจะใช้งานได้ต่อ ซึ่งอาจจะเกิดจากการที่เราไปแก้ให้มันใช้งานร่วมกันได้นั่นเอง

การที่เราแก้อะไรแบบนี้ ถ้าเรายังใช้วิธีการเช่น การก๊อป Source Code ออกมา และ เปลี่ยนชื่อ Version ไปเรื่อย ๆ ก็คือ ปวดหัวแน่นอน เพราะ Source Code บางไฟล์มันก็ต้องแก้ บางไฟล์มันก็ไม่ต้องแก้ เลข Version ไม่เท่ากันแน่นอน อันนี้ Version 2 อีกอันไป 10 แล้ว แล้วก็จะ งง ว่าแล้วไอ้ที่มีอยู่มันเป็นล่าสุดรึยัง โดยเฉพาะเมื่อเราทำงานในคอมพิวเตอร์หลายเครื่อง เครื่องเราเองบ้าง เครื่องที่ Lab บ้างอะไรก็ว่ากันไป

ทำให้เรื่องของ Version Control เป็นสิ่งสำคัญมาก ๆ เพราะมันจะเข้ามาช่วยเราแก้ปัญหาเรื่องของ Version ได้หมดเลย ตัวอย่าง Version Control Software ที่เราใช้กันอย่างแพร่หลายก็น่าจะเป็น Git เป็น Software ที่เราว่า ถ้าเขียนโปรแกรมไม่ว่าจะมาจากสายไหนก็ควรจะเรียนรู้ไว้จะดีมาก มันช่วยชีวิตเราได้เยอะมาก ๆ

สรุป

การเขียนโปรแกรมในงานวิจัยจริง ๆ มันก็ไม่ได้ต่างจากการเขียนโปรแกรมเพื่อใช้งานจริงเท่าไหร่ คือ เราต้องการผลออกมาเหมือนกัน แต่สิ่งที่หลาย ๆ คนอาจจะไม่ได้นึกถึงคือ Technical Debt ที่อาจจะเกิดขึ้น เมื่อเราต้องย้อนกลับมาแก้ไข Source Code ที่เราเขียน มันอาจจะใช้เวลาในการเริ่มทำเยอะขึ้น แต่เชื่อเถอะ จัดให้มันเรียบร้อยแต่วันนี้ พอปัญหามันเกิดขึ้น เราจะใช้เวลาไม่นานเลยในการแก้ปัญหา