Data Analyst › pandas

ทำความสะอาดข้อมูล — missing values, duplicates, วันที่หลายรูปแบบ

ประมาณ 35 นาที

เป้าหมายของบท

  • หาค่าที่ขาดหายด้วย isna()/notna() และจัดการด้วย dropna()/fillna()
  • หาและลบแถวที่ซ้ำกันด้วย duplicated()/drop_duplicates()
  • ทำความสะอาดข้อความ (.str.strip()/.str.title()) ให้ค่าที่ควรเหมือนกันไม่ถูกนับแยก
  • แปลงคอลัมน์วันที่ที่มีหลายรูปแบบปนกันในคอลัมน์เดียวให้ถูกต้องทั้งหมด
  • รู้ทันพฤติกรรม chained assignment ในปัจจุบันที่ไม่มี warning เตือนเลย

ข้อมูลจริงไม่เคยสะอาด

บทนี้ใช้ไฟล์ data/raw/customers_raw.csv — เวอร์ชัน “ดิบ” ของตาราง customers ที่มีปัญหาจริงปนอยู่: ค่าว่างบางช่อง, แถวซ้ำ, ชื่อเมืองเขียนไม่ตรงกัน, และวันที่สมัคร (signup_date) ที่เขียนได้หลายรูปแบบปนกัน เป้าหมายของบทนี้คือฝึกทำความสะอาดให้เหมือนของจริง

หา missing values

raw.isna().sum()

.isna() คืน True/False ทีละช่อง ต่อด้วย .sum() จะนับจำนวน True ในแต่ละคอลัมน์ทันที

ระวัง: ช่อง CSV ที่ว่างเปล่าจะถูกอ่านเป็นค่า missing (NaN) เสมอ ไม่ใช่สตริงว่าง '' ถ้าเขียน raw['email'] == '' เพื่อหาแถวที่ไม่มีอีเมล จะไม่เจอสักแถวเดียว ต้องใช้ .isna() เท่านั้น

จัดการค่าว่างได้สองทาง: dropna(subset=['col']) (ลบแถวที่คอลัมน์นั้นว่าง) หรือ fillna(ค่า) (เติมค่าแทนที่)

หาและลบแถวที่ซ้ำกัน

raw.duplicated().sum()      # นับจำนวนแถวที่ซ้ำ (แถวแรกของกลุ่มไม่นับ)
raw.duplicated(keep=False)  # ทำเครื่องหมายทุกแถวที่มีคู่ซ้ำ รวมแถวแรกด้วย
raw.drop_duplicates()       # ลบสำเนาส่วนเกินออก เหลือไว้แถวเดียวต่อกลุ่ม

ทำความสะอาดข้อความ

ค่าที่ควรเหมือนกัน (เช่น "Bangkok", " bangkok ", "BANGKOK ") มักถูกนับแยกกันเพราะช่องว่าง/ตัวพิมพ์ ไม่ตรงกัน แก้ด้วยการต่อเชนกัน:

raw['city'].str.strip().str.title()

ระวัง: ต้อง .strip() (ตัดช่องว่างหน้า-หลัง) ก่อนเสมอ แล้วค่อยจัดการตัวพิมพ์ ถ้าทำ .str.upper() อย่างเดียวโดยไม่ strip ก่อน ค่าที่มีช่องว่างเกินจะยังถูกนับแยกจากค่าที่ไม่มีช่องว่างอยู่ดี

วันที่หลายรูปแบบปนกันในคอลัมน์เดียว

คอลัมน์ signup_date เขียนได้ 3 แบบปนกัน: '2024-01-03' (ISO), '03/01/2024' (DD/MM/YYYY), '2024/01/03' (YYYY/MM/DD) — ตัวเลขวันเดียวกันแต่เขียนคนละแบบ

pd.to_datetime(raw['signup_date'])  # ❌ error: รูปแบบไม่ตรงกันทั้งคอลัมน์

ลองแก้แบบง่ายๆ ก่อน:

pd.to_datetime(raw['signup_date'], format='mixed')  # ✔ ไม่ error แต่...

ใช้ได้ไม่ error แต่ ผลลัพธ์ผิด สำหรับแถวแบบ DD/MM/YYYY เพราะ format='mixed' ให้ pandas เดารูปแบบ เอง และค่าเริ่มต้นจะตีความ '03/01/2024' เป็น “เดือน 03 วัน 01” (month-first) ทั้งที่จริงคือ 3 มกราคม

ลองเติม dayfirst=True เพื่อบอกว่า “ให้อ่านวันก่อนเดือน”:

pd.to_datetime(raw['signup_date'], format='mixed', dayfirst=True)  # ❌ ยังผิด!

ยืนยันแล้วว่ายังผิด — คราวนี้กลับไปทำให้สตริงแบบ ISO ที่ไม่กำกวมอยู่แล้ว (เช่น '2024-01-03') ถูกตีความผิดไปด้วย (dayfirst=True มีผลกับทุกสตริงที่ parse แบบ mixed ไม่ใช่แค่สตริงที่กำกวมจริงๆ) กลายเป็น '2024-03-01' ซึ่งผิด

วิธีที่ถูกต้อง: parse แยกทีละรูปแบบ แล้ว combine_first

d1 = pd.to_datetime(raw['signup_date'], format='%Y-%m-%d', errors='coerce')
d2 = pd.to_datetime(raw['signup_date'], format='%d/%m/%Y', errors='coerce')
d3 = pd.to_datetime(raw['signup_date'], format='%Y/%m/%d', errors='coerce')
parsed = d1.combine_first(d2).combine_first(d3)

หลักการ: ระบุ format= ที่ตรงเป๊ะทีละรูปแบบ พร้อม errors='coerce' (ค่าที่ไม่ตรงรูปแบบนั้นจะกลายเป็น NaT แทนที่จะ error) แล้วรวมผลทั้งหมดด้วย .combine_first() ตามลำดับ — แต่ละขั้นจะเติมเฉพาะช่องที่ ยังเป็น NaT อยู่ ผลสุดท้ายควรเช็ค .isna().sum() ว่าเป็น 0 เพื่อยืนยันว่าแปลงครบทุกแถวจริง

chained assignment: ไม่ error ไม่ warning แต่ก็ไม่มีผลอะไรเลย

sub = df[df['a'] > 1]
sub['a'] = 99          # ⚠️ ไม่ error, ไม่ warning, แต่ df ต้นฉบับไม่เปลี่ยนแปลง

df[เงื่อนไข] อาจคืนสำเนา (copy) ของข้อมูล การเขียนต่อด้วย ['col'] = ค่า (chained indexing) จึงอาจแก้ แค่สำเนาชั่วคราวที่ไม่มีใครอ้างอิงถึงอีกแล้ว — เวอร์ชันก่อนหน้าเคยมี SettingWithCopyWarning เตือน แต่ pandas ปัจจุบัน (ที่ใช้ Copy-on-Write เป็นค่าเริ่มต้น) ไม่แสดง warning นี้อีกต่อไป โค้ดจึงดูเหมือน รันผ่านปกติทุกอย่างทั้งที่ไม่ได้ผลอะไรเลย

ทางที่ปลอดภัยเสมอ:

df.loc[df['a'] > 1, 'a'] = 99   # ✔ แก้ไขข้อมูลต้นฉบับจริง

ตัวอย่างแก้โจทย์

กด "ขั้นต่อไป" ทีละขั้น ลองรันและแก้โค้ดในแต่ละขั้นได้

หา missing values ในข้อมูลลูกค้าดิบ

ข้อมูลลูกค้าดิบ (customers_raw) มีค่าว่างปนอยู่ อยากรู้ว่าคอลัมน์ไหนมีปัญหาบ้าง

  1. .isna() ให้ True/False ทีละช่อง .sum() ต่อจะนับจำนวน True ในแต่ละคอลัมน์

    Python

ทำความสะอาดชื่อเมืองที่เขียนไม่ตรงกัน

คอลัมน์ city ในข้อมูลดิบเขียนไม่ตรงกัน (ตัวพิมพ์ใหญ่-เล็ก ช่องว่างเกิน) ทั้งที่จริงมีแค่ 8 เมือง

  1. นับจำนวนค่าที่ไม่ซ้ำก่อน (ไม่รวมค่าว่าง)

    Python

แปลงคอลัมน์วันที่ที่เขียนหลายรูปแบบปนกัน

คอลัมน์ signup_date เขียนได้ 3 แบบปนกัน (ISO, DD/MM/YYYY, YYYY/MM/DD) ต้องแปลงให้ถูกต้องทั้งหมด

  1. ลองแบบง่ายสุดก่อน format='mixed' ให้ pandas เดารูปแบบทีละค่าเอง — ใช้งานได้ ไม่ error แต่ผลลัพธ์ผิดสำหรับแถวรูปแบบ DD/MM/YYYY (เช่น '03/01/2024' กลายเป็น 1 มี.ค. แทนที่จะเป็น 3 ม.ค.) เพราะไม่รู้ว่าต้องอ่านวันก่อนเดือน

    Python

เติมช่องว่าง

แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ

ยังไม่ได้ทำ

หาแถวที่ไม่มีอีเมล (missing) เก็บไว้ในตัวแปร result เติม ___ ให้ครบ

ยังไม่ได้ทำ

ลบแถวที่ซ้ำกันทั้งแถว เก็บไว้ในตัวแปร result เติม ___ ให้ครบ

แบบฝึกท้ายบท

บทนี้จะนับว่าเสร็จเมื่อผ่านข้อพื้นฐานและประยุกต์ครบ ข้อท้าทายได้ดาวเพิ่ม

🟢 พื้นฐาน

🟢 พื้นฐานยังไม่ได้ทำ

จำนวนค่าว่าง (NaN) ในแต่ละคอลัมน์ของข้อมูลลูกค้าดิบ เก็บไว้ในตัวแปร result

🟢 พื้นฐานยังไม่ได้ทำ

จำนวนแถวที่ไม่มีเมือง (city เป็นค่าว่าง) เก็บไว้ในตัวแปร n_missing_city

🟢 พื้นฐานยังไม่ได้ทำ

จำนวนแถวที่เหลือหลังลบแถวที่ไม่มีอีเมลออก (dropna) เก็บไว้ในตัวแปร n_remaining

🟢 พื้นฐานยังไม่ได้ทำ

จำนวนแถวทั้งหมดหลังลบแถวที่ซ้ำกันทั้งแถว เก็บไว้ในตัวแปร n_after

🟡 ประยุกต์

🟡 ประยุกต์ยังไม่ได้ทำ

ทำความสะอาดคอลัมน์ city (ตัดช่องว่างหน้า-หลัง แล้วทำเป็น Title Case) นับจำนวนเมืองที่ไม่ซ้ำ (ไม่รวมค่าว่าง) เก็บไว้ในตัวแปร n_cities

🟡 ประยุกต์ยังไม่ได้ทำ

จำนวนคู่แถวที่ซ้ำกันทั้งหมด (นับทุกแถวที่มีคู่ซ้ำ ไม่ใช่แค่แถวส่วนเกิน — ใช้ keep=False) เก็บไว้ในตัวแปร n_dup_rows

🟡 ประยุกต์ยังไม่ได้ทำ

แปลงคอลัมน์ signup_date ให้ถูกต้องทั้งคอลัมน์ด้วยเทคนิค parse-แยกรูปแบบ-แล้ว-combine_first (ห้ามใช้ dayfirst) เก็บผลลัพธ์ (Series ของวันที่) ไว้ในตัวแปร result

🟡 ประยุกต์ยังไม่ได้ทำ

ใช้คอลัมน์วันที่ที่แปลงถูกต้องแล้ว (เทคนิคเดียวกับ e7) หา 3 เดือนที่มีคนสมัครเยอะที่สุด เก็บไว้ในตัวแปร result

🔴 ท้าทาย

🔴 ท้าทายยังไม่ได้ทำ

สาธิตว่า chained assignment ไม่มีผลอะไรกับข้อมูลต้นฉบับ: สร้าง df = pd.DataFrame({'a': [1,2,3]}) แล้วลอง sub = df[df['a']>1]; sub['a'] = 99 เก็บค่า df['a'] หลังจากนั้นไว้ในตัวแปร after_chained (list)

🔴 ท้าทายยังไม่ได้ทำ

ทำความสะอาดข้อมูลลูกค้าดิบให้ครบวงจร: ลบแถวที่ไม่มีอีเมล, ทำความสะอาด city (strip+title), แปลง signup_date ให้ถูกต้อง (เทคนิคเดียวกับ e7), แล้วลบแถวที่อีเมลซ้ำกัน (drop_duplicates(subset=['email'])) นับจำนวนลูกค้าที่เหลือ เก็บไว้ในตัวแปร n_clean

Quiz

1. raw['email'] == '' ทำไมไม่เจอแถวที่ไม่มีอีเมลเลย
2. duplicated() ค่าเริ่มต้น (keep='first') กับ duplicated(keep=False) ต่างกันยังไง
3. ทำไม pd.to_datetime() เฉยๆ กับคอลัมน์ที่มีวันที่หลายรูปแบบปนกันถึง error
4. ทำไม format='mixed' กับ dayfirst=True ยังแก้ปัญหาคอลัมน์ที่ผสมกันจริงๆ ไม่ได้สมบูรณ์
5. chained assignment (df[cond]['col'] = ค่า) ในปัจจุบันมีพฤติกรรมยังไงเมื่อรันจริง

สรุป

  • raw['col'] == '' หาค่าว่างจาก CSV ไม่เจอ เพราะ pandas อ่านช่องว่างเป็น NaN ต้องใช้ isna()
  • ทำความสะอาดข้อความต้อง .strip() ก่อนเสมอ แล้วค่อยจัดการตัวพิมพ์ (.str.title()/.str.upper())
  • วันที่หลายรูปแบบปนกันต้อง parse แยกทีละรูปแบบ (errors='coerce') แล้วรวมด้วย combine_first — format='mixed' กับ dayfirst แก้ไม่ได้สมบูรณ์
  • chained assignment ในปัจจุบันไม่มี warning เตือนเลย แก้ไขข้อมูลต้องใช้ .loc[] เสมอ

คำถามสัมภาษณ์

เจอไฟล์ CSV ใหม่ ขั้นตอนแรกๆ ที่ควรทำเพื่อเช็คคุณภาพข้อมูลมีอะไรบ้าง

ดู shape/dtypes ก่อนเพื่อรู้ขนาดและชนิดข้อมูล ตามด้วย isna().sum() ต่อคอลัมน์ (มีค่าว่างตรงไหนบ้าง) และ duplicated().sum() (มีแถวซ้ำไหม) จากนั้นเช็ค unique()/nunique() ของคอลัมน์ข้อความสำคัญ เพื่อเจอปัญหาแบบที่เจอในบทนี้ (ตัวพิมพ์ไม่ตรงกัน ช่องว่างเกิน) ก่อนเริ่มวิเคราะห์จริง

คอลัมน์วันที่มีหลายรูปแบบปนกัน จะแปลงให้ถูกต้องทั้งหมดยังไง

ห้ามใช้ pd.to_datetime() เฉยๆ (จะ error) และห้ามใช้ format='mixed' กับหรือไม่กับ dayfirst=True เพียงอย่างเดียว (ทั้งสองแบบให้ผลผิดสำหรับบางรูปแบบ) วิธีที่ถูกต้องคือ parse แยกทีละรูปแบบด้วย format= ที่ตรงเป๊ะและ errors='coerce' (ให้ค่าที่ไม่ตรงกลายเป็น NaT แทนที่จะ error) แล้วรวมผล ทั้งหมดด้วย .combine_first() ตามลำดับ สุดท้ายควรเช็คว่า .isna().sum() ของผลลัพธ์เป็น 0 เพื่อยืนยัน ว่าแปลงครบทุกแถวจริง

ทำไมการแก้ไข DataFrame ผ่าน chained indexing ถึงเป็นอันตราย และปัจจุบัน (pandas ที่ใช้ Copy-on-Write เป็นค่าเริ่มต้น) มีพฤติกรรมต่างจากเดิมยังไง

df[เงื่อนไข] อาจคืนสำเนา (copy) ของข้อมูล ไม่ใช่ตัวข้อมูลจริง การเขียนต่อด้วย ['col'] = ค่า (chained indexing) จึงอาจแก้ไขแค่สำเนาชั่วคราวที่ไม่มีใครอ้างอิงถึงอีกแล้ว

ปัจจุบันปัญหานี้อันตรายกว่าเดิม เพราะ pandas เวอร์ชันที่ใช้ Copy-on-Write เป็นค่าเริ่มต้นไม่แสดง SettingWithCopyWarning เตือนเหมือนเวอร์ชันก่อนหน้าอีกต่อไป — โค้ดดูเหมือนรันผ่านปกติทุกอย่าง แต่ข้อมูลต้นฉบับไม่ถูกแก้ไขจริงเลย ทางที่ปลอดภัยเสมอคือเขียนในคำสั่งเดียวด้วย .loc[เงื่อนไข, 'col'] = ค่า