Tutorial

จัดการ DataFrame ใหญ่ลืม ยังไงให้ต๊าชชช

By Arnon Puitrakul - 15 กันยายน 2021 - 2 min read min(s)

จัดการ DataFrame ใหญ่ลืม ยังไงให้ต๊าชชช

ไม่กี่วันก่อน ไปเจอ Excel ขนาด 30K Rows กว่า ๆ แล้วต้อง Apply VLOOKUP เข้าไป ลากทั้ง Columns เข้าไปก็คือ หน่วงไปเลยจ้าาา เลยทำให้กลับมานึกถึงว่าถ้า เอ๊ะ เราทำบน Pandas DataFrame ที่เราคุ้นเคยละ เราจะทำยังไงให้เราสามารถ Apply Function บน Pandas DataFrame ได้เร็วที่สุด วันนี้เราลองมาค่อย ๆ ลองดูทีละวิธี และ ลองมาเปรียบเทียบกันดีกว่าว่า วิธีไหนที่จะเร็วที่สุดกันแน่ (เราว่าหลาย ๆ คนรู้คำตอบแหละ)

วิธีที่ 1 : Loop มันตรง ๆ นี่แหละ จะไปยากอะไร๊

วิธีแรกบอกเลยว่า มาแบบเหงา ๆ หน่วง ๆ บ้าน ๆ สุด ๆ ไปเลย เรารู้ว่า เราสามารถใช้คำสั่ง iloc เพื่อเป็นการเลือกตำแหน่งของ Cell บน DataFrame ของเรามาได้

def slicing_df (df) :
    age_series = []

    for i in range (len(df)) :
        age_series.append(2021 - df.iloc[i]['birth_year'])

    df['age'] = age_series

    return df

สิ่งที่เราทำใน Function แรก เรา ก็ไม่มีอะไรเลย เราประกาศ age_series สำหรับเก็บอายุที่คำนวณเสร็จแล้วขึ้นมาเป็น List ทั่ว ๆ ไปของ Python จากนั้น เราก็จะ Loop โดยการเพิ่ม Index ไปเรื่อย ๆ จนถึงจำนวน Row บน df ที่เราใส่เข้ามา ในนั้น เราก็จะให้มันไปดึงค่าปีเกิดผ่านการใช้ iloc แล้วหักกับปีปัจจุบัน หรือก็คือปี 2021 เราก็จะได้อายุ และ Append เข้าไปใน List ที่เราเตรียมไว้ จากนั้น เราก็เอา List ที่เราใส่ผลลัพธ์ไว้ Pass ไปเป็น Column ใหม่ใน DataFrame สุดท้ายก็ Return DataFrame กลับไป

วิธีที่ 2 : Pandas Iterrows

การเข้าถึงแต่ละค่าตรง ๆ ผ่าน iloc เลยมันอาจจะช้าไปหน่อย ทำให้ใน Pandas เตรียมอีก Function เป็น Generator ตัวนึง เพื่อให้เราสามารถ Loop และเข้าถึงข้อมูลทีละ Row ได้เลย ผ่าน Function ที่ชื่อว่า Iterrows

@timeme
def iter_row_df (df) :
    age_series = []

    for index, row in df.iterrows():
        age_series.append(2021 - row['birth_year'])
    
    df['age'] = age_series

    return df

สิ่งที่เราทำจริง ๆ ก็เหมือนเดิมจาก Function ก่อนหน้าที่เราทำกันเลย แต่เราเปลี่ยนจาก Loop ตาม Index เป็น Loop โดยใช้ Generator แทน แค่นั้นเลย ส่วนพวก Logic ในการทำงาน ก็ไม่ตามจาก Function ก่อนหน้าเลย จริง ๆ เมื่อก่อน เราก็ใช้วิธีนี้เหมือนกัน แล้วทำงานได้ช้ามาก ๆ

วิธีที่ 3 : Pandas apply()

ตอนนั้น เราก็ใช้วิธีที่ 2 ทำงานกับ DataFrame ขนาดใหญ่มาก ๆ ปรากฏว่า มันโคตรช้าเลย ทำให้เราไม่โอเคเท่าไหร่ จนเลื่อน ๆ ไปใน Document ของ Pandas ไปเจอ apply() พอดี เห้ย น่าสนใจเลย ลองเอามาใช้ดู

@timeme
def apply_df (df) :
    df['age'] = df['birth_year'].apply(lambda x:2021-x)
    return df

เราจะเห็นได้เลยว่า สั้นกว่าเดิมเยอะมาก ๆ จริง ๆ แล้วถ้าเราไม่ได้ยัดใส่ Function อีกชั้น มันก็จะยาวแค่บรรทัดเดียวเขียนไว้ใน main เลยยังได้ สิ่งที่เราทำคือ เราเลือก Column birth_year ออกมาผ่านการเรียก Index ปกติที่เราทำกันเลย ทำให้เราจะได้ Series ออกมา ซึ่งวิธีนี้ ไม่ได้ช้าเลย เพราะถ้าเรากลับไปดูใน Arch ของ Pandas จริง ๆ พวก DataFrame มันประกอบด้วยหลาย ๆ Series เข้าด้วยกัน ทำให้การเข้าถึง Series มันเลยเร็วเหมือนระดับ O(1) ได้เลย จากนั้น เราก็ใช้คำสั่ง apply()

ตัวคำสั่ง apply() มันจะรับ Function ตัวนึงเข้าไป แล้วเอาไปรันกับแต่ละ Item ใน Series ได้เลย จากในตัวอย่างด้านบน สิ่งที่เราทำคือ เราก็สร้าง Anonymous Function ง่าย ๆ ขึ้นมาบอกว่า ให้รับ x และ Return 2021 - x กลับไปก็เป็นอันจบ

ถามว่า แล้วทำไมวิธีนี้มันเร็วจังละ มันเป็นเพราะ ไส้ในของ Pandas ถ้าเราเข้าไปดูใน Source Code จริง ๆ มันจะทำการแปลง Series เป็น Numpy Array แล้วค่อยทำงาน ซึ่งแน่นอนว่า ถ้าใครที่เขียน Python มาก่อน น่าจะรู้กันอยู่แล้วว่า Numpy Array มันก็ทำงานได้เร็วมาก ๆ อยู่แล้ว เพราะไส้มันเขียนด้วย C อีกที และ เขาก็ Optimise ตัว Numpy มาดี เลยทำให้มันเร็วมาก ๆ นั่นเอง

วิธีที่ 4 : Pandas Vectorisation

วิธีสุดท้าย มันเกิดจากความบังเอิญ และความฮ่าของตัวเองล้วน ๆ ตอนนั้นลองเขียนขำ ๆ ไม่รู้มาก่อนเลย แล้วปรากฏว่าได้เฉย จนไปลองหา ๆ ว่ามันเรียกว่าอะไรใน Stackoverflow ดูก็ถึงบางอ้อว่า มันเรียกว่า Vectorisation

@timeme
def vectorisation_df (df) :
    df['age'] = 2021 - df['birth_year']
    return df

การทำ Vectorisation บน Pandas มันง่ายมาก ๆ คือ เราก็สามารถเอาจำนวนเต็ม ไปทำ Operation บางอย่างกับ Series ได้ตรง ๆ เลย มันก็จะเป็นการทำ Vectorisation ไปเลย เพราะมันง่ายแบบนี้ไง เลยทำให้เราเจอมันโดยบังเอิญจนต้องไปหาเลยว่ามันเรียกอะไร ฮ่าไปอีก อ่านมาน่าจะ งง ว่า แล้ว Vectorisation มันคืออะไร เอาสั้น ๆ คือมันเป็นการทำ Operation อะไรบางอย่างทั้ง Array เหมือนกับที่เราทำกันด้านบน เราก็ทำทั้ง Series ไปเลย นั่นเอง

มาทดลองกัน

import pandas as pd
from joblib import delayed, Parallel

sample_df = pd.DataFrame({'birth_year': [randint(1970, 2021) for i in range(10000000)]})

ตัวอย่างข้อมูลที่เราใช้ทดสอบกันในวันนี้ เราจะทำการสุ่มเลขขึ้นมา ช่วงระหว่าง 1970 - 2021 ผ่านคำสั่ง randint ที่เป็น Built-in Function ของ Python ในการ Random Integer ขึ้นมา พร้อมกับแปลงให้เป็น Pandas DataFrame ไป

import functools
import time
import pandas as pd

def timeme (func) :
    
    @functools.wraps(func)
    def measure_time (*args,**kwargs) :
        start_time = time.time()
        result = func(*args,**kwargs)
        elapsed = time.time() - start_time
        
        print(func.__name__, 'ran for', elapsed, 'sec(s)')
        return result        

    return measure_time

ส่วนการวัดค่า เราจะใช้เวลาเป็นตัวตัดสินเป็นหลักในวันนี้ ซึ่ง เราแบ่งออกเป็นหลายการทดลองดังนั้น เราจะเขียน Decorator ขึ้นมา ใช้สำหรับ Pass Function การทดลองของเราเข้าไป และให้มัน Print เวลาในการทำงานของ Function นั้น ๆ ออกทางหน้าจอเรา

slicing_df(sample_df)
iter_row_df(sample_df)
apply_df(sample_df)
vectorisation_df(sample_df)

สุดท้าย เราก็จะเรียก แต่ละการทดลองขึ้นมา เราลองมาดูเวลาในการทำงานกันว่าแต่ละการทดลองมันจะใช้เวลาต่างกันขนาดไหน

slicing_df ran for 504.43618512153625 sec(s)
iter_row_df ran for 271.53919315338135 sec(s)
apply_df ran for 2.2525229454040527 sec(s)
vectorisation_df ran for 0.03862118721008301 sec(s)

เวลาที่ได้ออกมา เราบอกก่อนนะว่า ทุกการทดลองเราใช้จำนวนข้อมูลเท่ากัน ชุดเดียวกันเลยนะ เวลามันดูโคตรต่างเลย มัน Ranging ตั้งแต่ 504 วินาที หรือ 8.4 นาที จนไประดับ ไม่ถึงวินาทีเลย อย่างต่างเลย ถ้าเราลองดูเข้าไปที่ iter_row กับ apply_df จะเห็นได้ชัดเลยว่า จำนวนเวลาต่างกันได้อย่างชัดเจนเลย

เราลองหาข้อมูลเพิ่มอีกหน่อย เราลองมา แบ่งการทดลองออกมาดีกว่า เราจะใช้ข้อมูลจำนวนที่ต่างกันตั้งแต่ 1,000 , 10,000 , 100,000 , 1,000,000 , 10,000,000 และ 100,000,000 แล้วลองมาดูว่า แต่ละวิธีมันจะมีพฤติกรรมอย่างไร

ดูผ่าน ๆ ทุกวิธีเมื่อเราเพิ่มจำนวนข้อมูลมากขึ้นเรื่อย ๆ เวลาดูจะเพิ่มขึ้นเป็น Linear หมดเลย โดยเฉพาะตัวสีน้ำเงิน (Slicing Method) และ สีแดง (iterrow Function) ที่เพิ่มเป็น Linear อย่างชัดเจน ก็จะเป็นไปตามสมมุติฐานที่ตั้งไว้แต่แรกว่า ถ้าเราเพิ่มข้อมูลเวลา ก็ต้องเพิ่มขึ้นเป็นเงาตามตัว

จากการทดลองนี้ เราก็สามารถสรุปได้ว่า ใน 4 วิธีที่เราทำการทดลองกันมา การใช้ Vectorisation เป็นวิธีที่เร็วที่สุดใน 4 วิธีที่เราเลือกมาใช้งานกันเลย

ปล. จริง ๆ มันมีข้อมูลที่น่าสนใจจากการทดลองมากกว่านี้อีก แต่ขออุบไว้ในบทความหน้าละกันนะ รายละเอียดมันเยอะมาก ๆ

ทำไม Vectorisation มันเร็วมาก ๆ

Vectorisation ถ้าเล่าง่าย ๆ ให้ลึกขึ้นหน่อยคือ การที่เราเขียน Loop นี่แหละ แต่แทนที่เราจะทำรอบละตัว เราสามารถทำรอบละหลาย ๆ ตัวได้ นั่นทำให้จำนวนรอบเราก็จะน้อยลง ตัวอย่างเช่น เราบอกว่า เรามีข้อมูลอยู่ 1,000 ตัว ถ้าเรา Loop ปกติ เราทำรอบละตัว เราก็จะต้องใช้ทั้งหมด 1,000 รอบ เพื่อคำนวณ แต่ถ้าเราบอกว่า เราทำ Vectorisation บ้างละ เช่น CPU ที่เราใช้อนุญาติให้เรา ทำได้ 10 ชิ้น พร้อม ๆ กัน นั่นแปลว่า เราจะใช้แค่ 1000 หาร ด้วย 10 หรือ 100 รอบในการทำงานเท่านั้น ทำให้ มันสามารถทำงานได้เร็วมาก ๆ นั่นเอง

การทำ Vectorisation จะเป็น Feature นึงใน CPU ที่รองรับการทำงานแบบ SIMD (Single Instruction Multiple Data) ที่อนุญาติให้ใน 1 Instruction สามารถทำงานกับข้อมูลหลาย ๆ ชิ้นได้ ซึ่งพวก CPU ในปัจจุบันก็มีจะมีการ Implement Feature ในส่วนนี้มากขึ้นเรื่อย ๆ

เวลาเราไปดู Datasheet ของ CPU มันจะมี Instruction ของการทำ Vectorisation อยู่เหมือนกัน ตัวอย่างของฝั่ง Intel ก็จะใช้ชื่อว่า AVX (Advanced Vector Extension) ถ้าตัวที่เราได้ยินบ่อย ๆ น่าจะเป็น AVX2 กับ AVX512 (เราจะเจอในพวก Data Centre CPU ไม่ก็พวก AI-Intensive CPU, Accelerator) ตัวอย่าง AVX512 ไปดูใน Datasheet ของ Intel บอกว่า เขาใช้ Register ขนาด 512-bits ในการเก็บข้อมูลใน CPU ทำให้ เราสามารถยัด ตัวเลขขนาด 64-bits ได้ 8 ตัว หรือไม่ก็ตัวเลขแบบ 32-bits ได้ 16 ตัวพร้อม ๆ กันไปเลย นั่นหมายถึงใน 1 Instruction Clock เราทำพร้อม ๆ กันได้สูงสุดถึง 16 ตัวพร้อม ๆ กันเลย โคตรเร็ว

ถ้ามันดีขนาดนี้ ทำไมผู้ผลิต CPU เขาไม่ใส่มาให้เยอะกว่านี้ละ อย่างแรกเลยคือ มันแพง !!! ถ้าเราขยายขนาดของข้อมูลที่รองรับได้ทำให้ขนาดของ Register ก็ต้องใหญ่ขึ้นตามไปด้วยกัน ซึ่งมันเป็นอะไรที่แพงมาก ๆ นอกจากนั้น ยังต้องเพิ่มส่วนของวงจรที่ใช้ในการคำนวณเพิ่มอีก ซึ่งในการใช้งานทั่ว ๆ ไปตาม User ปกติเลย เราไม่ได้มีความจำเป็นที่จะต้องใช้เยอะขนาดนั้นเลย เอาพื้นที่ ๆ เหลือ ไปใส่ วงจรอื่น ๆ น่าจะดีกว่า เช่นพวก Video Encoder และ Decoder ที่เรามั่นใจได้เลยว่า User เดี๋ยวนี้ยังไง ๆ ก็ต้องเล่น Video แน่ ๆ ทำให้ผู้ใช้ Happy กว่าเยอะ

แต่พวกชุดคำสั่งพวกนี้ มันจะไปได้เปรียบในพวกงานเฉพาะทางจริง ๆ เช่นพวก Deep Learning ที่ถ้าเราทำ Vectorisation ได้ดีเท่าไหร่ Performance ที่เราได้ย่อมสูงขึ้นแน่นแน เพราะ Deep Learning มันใช้งาน Vectorisation เต็ม ๆ เลย ทำให้ Intel AVX512 มันเลยลอยไปอยู่ในพวก Workstation หรือ Data Centre Hardware ไป ส่วน CPU บ้าน ๆ อย่างพวกเราฝั่ง Intel ก็จะใช้เป็น AVX2 ที่รองรับขนาดของข้อมูลที่เล็กลงมาเท่าตัวของ AVX512 หรืออีกพวกที่ CPU หลาย ๆ เจ้ารองรับก็คือพวก SSE (Streaming SIMD Extensions)

สรุป

วันนี้เราได้ลองหลาย ๆ วิธีในการทำงานกับ Pandas DataFrame ว่าวิธีไหนมันเร็วที่สุดซึ่งแน่นอนว่า การทำ Vectorisation เป็นวิธีที่เร็วที่สุดแล้วในการทำงาน เพราะมันทำให้เราสามารถคำนวณพร้อม ๆ ได้หลาย ๆ Item ใน 1 Instruction ไปเลย ซึ่ง Performance ที่เราเห็นก็บอกได้เลยว่า Why not?? มันต้องใช้แล้ว Vectorisation ถึงแม้ว่า เราจะต้องเขียนหลายบรรทัดขึ้น และ วน ๆ บน DataFrame หลาย ๆ รอบ แต่มันก็เร็วกว่า การ Loop และทำทุกอย่างในการ Loop ครั้งเดียวอยู่ดี เพราะ CPU มันออกแบบมาให้ทำงานผ่าน Vectorisation ได้เร็วกว่านั่นเอง ไว้ในโอกาสหน้า เราจะมาเล่าเพิ่มว่า ถ้าเราต้องการจะแปลง Function ปกติของเราให้ทำโดยใช้ Vectorisation มันต้องทำยังไงบ้าง และ ทำยังไงให้เร็วขึ้นอีก