ทำความสะอาดข้อมูล — 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) มีค่าว่างปนอยู่ อยากรู้ว่าคอลัมน์ไหนมีปัญหาบ้าง
.isna()ให้ True/False ทีละช่อง.sum()ต่อจะนับจำนวน True ในแต่ละคอลัมน์
ทำความสะอาดชื่อเมืองที่เขียนไม่ตรงกัน
คอลัมน์ city ในข้อมูลดิบเขียนไม่ตรงกัน (ตัวพิมพ์ใหญ่-เล็ก ช่องว่างเกิน) ทั้งที่จริงมีแค่ 8 เมือง
นับจำนวนค่าที่ไม่ซ้ำก่อน (ไม่รวมค่าว่าง)
แปลงคอลัมน์วันที่ที่เขียนหลายรูปแบบปนกัน
คอลัมน์ signup_date เขียนได้ 3 แบบปนกัน (ISO, DD/MM/YYYY, YYYY/MM/DD) ต้องแปลงให้ถูกต้องทั้งหมด
ลองแบบง่ายสุดก่อน
format='mixed'ให้ pandas เดารูปแบบทีละค่าเอง — ใช้งานได้ ไม่ error แต่ผลลัพธ์ผิดสำหรับแถวรูปแบบ DD/MM/YYYY (เช่น'03/01/2024'กลายเป็น 1 มี.ค. แทนที่จะเป็น 3 ม.ค.) เพราะไม่รู้ว่าต้องอ่านวันก่อนเดือน
เติมช่องว่าง
แทน ___ ด้วยโค้ดที่ถูกต้อง แล้วกดตรวจคำตอบ
หาแถวที่ไม่มีอีเมล (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
สรุป
- 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'] = ค่า