Danh sách bài viết

Bài 37: Lọc dữ liệu với boolean mask

Lọc DataFrame trong Pandas bằng boolean mask: so sánh trả về Series bool, kết hợp điều kiện với & | ~, các phương thức isin / between / isna / notna, str.contains cho chuỗi, df.query() SQL-like, gán giá trị với .loc, SettingWithCopyWarning và mẹo dùng .copy().

24/05/2026
12 phút đọc
0 lượt xem
1

Mục tiêu bài học

  • Hiểu boolean mask: so sánh trên Series trả về Series bool, dùng làm chỉ mục cho DataFrame.
  • Kết hợp nhiều điều kiện đúng cú pháp với &, |, ~ và dấu ngoặc.
  • Dùng isin, between, isna, notna, str.contains để viết filter gọn.
  • Biết dùng df.query(), gán giá trị qua .loc[mask, col], tránh SettingWithCopyWarning.
2

Vì sao cần lọc dữ liệu

DataFrame thực tế thường vài chục cột, vài triệu dòng. Phần lớn việc EDA và chuẩn bị data cho ML là chọn subset theo điều kiện:

  • Lấy sample của 1 class (ví dụ class 1) để train binary classifier.
  • Lọc outlier theo z-score hoặc IQR trước khi fit model tuyến tính.
  • Giữ lại record có timestamp trong khoảng thử nghiệm A/B.
  • Báo cáo riêng nhóm khách VIP, theo thành phố, theo trạng thái.

Pandas dùng boolean mask làm công cụ chính cho việc này. Mask là một Series bool cùng độ dài DataFrame, mỗi vị trí trả lời "row này có giữ lại không".

3

Boolean Series — nền tảng của filter

Phép so sánh trên Series trả về Series bool theo từng phần tử:

import pandas as pd

df = pd.DataFrame({
    "name":   ["An", "Binh", "Cuong", "Dung", "Hoa"],
    "age":    [17, 22, 35, 19, 41],
    "salary": [0, 1200, 2500, 800, 3200],
})

mask = df["age"] > 18
print(mask)
# 0    False
# 1     True
# 2     True
# 3     True
# 4     True
# Name: age, dtype: bool

Đưa mask vào dấu ngoặc vuông của DataFrame để giữ lại các row mà mask = True:

adults = df[df["age"] > 18]
print(adults)
#     name  age  salary
# 1   Binh   22    1200
# 2  Cuong   35    2500
# 3   Dung   19     800
# 4    Hoa   41    3200

Mask phải cùng độ dài và cùng index với DataFrame. Pandas align theo index, không phải theo vị trí — đây cũng là nguyên nhân hay gặp lỗi khi truyền mask sinh từ một DataFrame khác.

4

Toán tử logic &, |, ~

Kết hợp nhiều điều kiện dùng toán tử bitwise chứ không phải từ khoá Python:

  • &and theo phần tử.
  • |or theo phần tử.
  • ~not, đảo bit.

Bắt buộc bọc mỗi điều kiện trong dấu ngoặc vì & / | có precedence cao hơn >, <, ==:

import pandas as pd

df = pd.DataFrame({
    "name":   ["An", "Binh", "Cuong", "Dung", "Hoa"],
    "age":    [17, 22, 35, 19, 41],
    "salary": [0, 1200, 2500, 800, 3200],
})

# Đúng: mỗi điều kiện trong cặp ngoặc riêng
mask = (df["age"] > 18) & (df["salary"] > 1000)
print(df[mask])

# Sai cú pháp — TypeError vì Python parse thành df["age"] > (18 & df["salary"]) > 1000
# df[df["age"] > 18 & df["salary"] > 1000]

Không dùng and, or, not trên Series — pandas sẽ raise ValueError: The truth value of a Series is ambiguous. Lý do: and/or của Python ép Series về 1 giá trị bool duy nhất, nhưng Series có nhiều phần tử nên không xác định được.

# Lỗi điển hình
# df[df["age"] > 18 and df["salary"] > 1000]
# ValueError: The truth value of a Series is ambiguous.
# Use a.empty, a.bool(), a.item(), a.any() or a.all().

# Đảo điều kiện với ~
not_adult = df[~(df["age"] > 18)]   # tương đương df["age"] <= 18
5

isin, between, isna, notna

Pandas có sẵn các phương thức trả Series bool, đọc dễ hơn chuỗi | dài:

import pandas as pd
import numpy as np

df = pd.DataFrame({
    "name":  ["An", "Binh", "Cuong", "Dung", "Hoa"],
    "age":   [17, 22, 35, 19, 41],
    "city":  ["HN", "HCM", "DN", "HN", "HP"],
    "email": ["[email protected]", None, "[email protected]", np.nan, "[email protected]"],
})

# isin: thuộc tập giá trị cho trước
df_big_city = df[df["city"].isin(["HN", "HCM"])]

# between: nằm trong khoảng (mặc định inclusive="both")
df_young = df[df["age"].between(20, 30)]                 # 20 <= age <= 30
df_strict = df[df["age"].between(20, 30, inclusive="neither")]  # 20 < age < 30

# isna / notna: kiểm tra NaN
df_missing_email  = df[df["email"].isna()]
df_has_email      = df[df["email"].notna()]

Kết hợp được với & / | như mask thường:

mask = df["city"].isin(["HN", "HCM"]) & df["age"].between(20, 40)
print(df[mask])

Đảo bằng ~: df[~df["city"].isin(["HN", "HCM"])] — các thành phố khác HN / HCM.

6

Lọc theo chuỗi với str.contains

Cột object/string có accessor .str với họ phương thức để filter chuỗi:

import pandas as pd

df = pd.DataFrame({
    "name":  ["Nguyen Van An", "Tran Thi Binh", "Nguyen Hoa", None, "Le Cuong"],
    "email": ["[email protected]", "[email protected]", "[email protected]",
              "[email protected]", "[email protected]"],
})

# Chứa chuỗi con
df[df["name"].str.contains("Nguyen", na=False)]

# Bắt đầu / kết thúc
df[df["email"].str.endswith("@gmail.com")]
df[df["name"].str.startswith("Le", na=False)]

Tham số na=False rất hay quên: nếu cột có giá trị NaN, str.contains trả NaN cho dòng đó và Pandas báo lỗi khi dùng làm chỉ mục. Truyền na=False để coi NaN như "không match".

str.contains mặc định bật regex=True. Truyền pattern regex để filter mạnh hơn:

import pandas as pd

s = pd.Series(["abc123", "no-digit", "42 items", None])

# Chứa ít nhất 1 chữ số
s[s.str.contains(r"\d", regex=True, na=False)]

# Bắt đầu bằng số
s[s.str.contains(r"^\d+", regex=True, na=False)]

Muốn match literal có ký tự đặc biệt (., ?, (...), tắt regex: str.contains("a.b", regex=False) hoặc dùng re.escape.

7

df.query() — SQL-like

df.query(expr) nhận điều kiện dạng chuỗi, viết gần như SQL. Khác với mask, trong query dùng được and / or / not hoặc & / | / ~.

import pandas as pd

df = pd.DataFrame({
    "name":   ["An", "Binh", "Cuong", "Dung", "Hoa"],
    "age":    [17, 22, 35, 19, 41],
    "salary": [0, 1200, 2500, 800, 3200],
    "city":   ["HN", "HCM", "DN", "HN", "HP"],
})

# Cú pháp giống SQL
df.query("age > 18 and salary > 1000")

# isin
df.query("city in ['HN', 'HCM']")

# Tham chiếu biến Python bằng @
threshold = 18
df.query("age > @threshold")

So với boolean mask:

  • Ưu: ngắn, dễ đọc, không phải lặp lại df["..."]. Tốt cho điều kiện nhiều cột.
  • Nhược: column name có dấu cách / ký tự đặc biệt phải bọc backtick ("`my col` > 0"); không kiểm tra cú pháp lúc viết code, lỗi chỉ phát sinh runtime; chậm hơn mask một chút trên DataFrame rất nhỏ.

Quy tắc thực dụng: filter có 1–2 điều kiện đơn giản → boolean mask. Filter dài, nhiều phép so sánh, đọc giống SQL → query.

8

Gán giá trị với .loc và mask

Lọc thường không dừng ở việc đọc — còn dùng để gán giá trị có điều kiện. Cú pháp chuẩn là df.loc[mask, "col"] = value:

import pandas as pd

df = pd.DataFrame({
    "name": ["An", "Binh", "Cuong", "Dung"],
    "age":  [17, 22, 35, 19],
})

# Thêm cột category dựa trên điều kiện
df["category"] = "minor"
df.loc[df["age"] >= 18, "category"] = "adult"

print(df)
#     name  age category
# 0    An   17    minor
# 1  Binh   22    adult
# 2 Cuong   35    adult
# 3  Dung   19    adult

Cũng có thể lấy nhiều cột cùng một mask:

subset = df.loc[df["age"] >= 18, ["name", "category"]]

Nhiều người mới hay viết df[df["age"] >= 18]["category"] = "adult". Cách này thường không update df và sinh ra SettingWithCopyWarning — xem mục tiếp theo.

9

SettingWithCopyWarning

Warning này phát sinh khi gán giá trị trên kết quả của filter "chained":

import pandas as pd

df = pd.DataFrame({"age": [17, 22, 35], "score": [5, 7, 9]})

sub = df[df["age"] > 18]   # sub có thể là view hoặc copy — không guarantee
sub["score"] = 10           # SettingWithCopyWarning

Pandas không chắc sub là view của df hay là DataFrame mới, nên không rõ phép gán có tác dụng lên df hay không. Hai cách fix rõ ràng:

# Cách 1: gán thẳng trên df gốc bằng .loc
df.loc[df["age"] > 18, "score"] = 10

# Cách 2: nếu cần một subset độc lập, gọi .copy() rồi mới sửa
sub = df[df["age"] > 18].copy()
sub["score"] = 10           # không còn warning, df gốc không đổi

Lưu ý phiên bản: Pandas 3.x sẽ bật Copy-on-Write mặc định (pd.options.mode.copy_on_write = True). Khi đó sub luôn là copy, hành vi tường minh hơn — nhưng quy tắc dùng .loc để gán trên df gốc vẫn là cách viết khuyến nghị.

10

View hay copy?

Một câu hỏi hay được hỏi: "Khi lọc df[mask], kết quả là view hay copy của df gốc?"

Câu trả lời chính xác: không guarantee. Pandas có thể trả view, có thể trả copy, tuỳ dtype và layout bộ nhớ. Vì vậy đừng dựa vào việc sửa kết quả filter sẽ (hay không) ảnh hưởng df gốc.

Quy tắc thực dụng:

  • Muốn đọc subset → df[mask] là đủ.
  • Muốn sửa df gốc theo điều kiện → dùng df.loc[mask, col] = ....
  • Muốn DataFrame mới độc lập để biến đổi → df[mask].copy().

Trên Pandas 3.x bật Copy-on-Write, mọi filter mặc định trả "copy logic": sửa kết quả không bao giờ ảnh hưởng df gốc, và không còn SettingWithCopyWarning nữa.

11

Use case AI / ML

  • Stratified sampling: lấy n sample mỗi class — df[df["label"] == c].sample(n) cho từng class rồi pd.concat.
  • Lọc outlier theo z-score: mask = (df["x"] - df["x"].mean()).abs() / df["x"].std() < 3 giữ lại điểm trong 3 sigma.
  • Lọc outlier theo IQR: tính q1, q3, giữ df[df["x"].between(q1 - 1.5 * iqr, q3 + 1.5 * iqr)].
  • Cắt window thời gian: df[df["ts"].between("2025-01-01", "2025-03-31")] để chỉ giữ Q1.
  • Lọc text theo từ khoá: df[df["text"].str.contains(r"\b(?:gpu|cuda)\b", regex=True, case=False, na=False)] trước khi đưa qua tokenizer / embedding.
  • Lọc dòng không có label: df[df["label"].notna()] trước khi train_test_split.
12

Code Python tổng hợp

DataFrame sinh viên với nhiều cột; lần lượt dùng mask, isin, between, str.containsquery:

import numpy as np
import pandas as pd

df = pd.DataFrame({
    "id":     [1, 2, 3, 4, 5, 6, 7, 8],
    "name":   ["Nguyen An", "Tran Binh", "Le Cuong", "Pham Dung",
               "Nguyen Hoa", "Do Khanh", "Nguyen Lan", "Vu Minh"],
    "age":    [18, 22, 35, 19, 41, 28, 17, 30],
    "score":  [8.5, 7.0, 9.2, 6.5, 8.0, np.nan, 7.8, 9.0],
    "city":   ["HN", "HCM", "DN", "HN", "HP", "HCM", "HN", "DN"],
})

# 1) Boolean mask đa điều kiện — nhớ dấu ngoặc
mask = (df["age"].between(20, 35)) & (df["score"] >= 8.0)
print("Adult điểm cao:")
print(df[mask])

# 2) isin: chỉ giữ 3 thành phố
big_city = df[df["city"].isin(["HN", "HCM", "DN"])]
print("3 thành phố lớn:", big_city.shape)

# 3) str.contains: tên bắt đầu bằng "Nguyen"
nguyen = df[df["name"].str.contains(r"^Nguyen", regex=True, na=False)]
print(nguyen[["id", "name"]])

# 4) Lọc missing label trước khi train
ready = df[df["score"].notna()].copy()

# 5) query — tương đương mask ở (1)
q = df.query("20 <= age <= 35 and score >= 8.0")
print("Bằng query:", q.shape)

# 6) Gán cột phân loại với .loc — tránh SettingWithCopyWarning
df["group"] = "khac"
df.loc[df["score"] >= 8.5, "group"] = "gioi"
df.loc[df["score"].between(7.0, 8.5, inclusive="left"), "group"] = "kha"
print(df[["name", "score", "group"]])
13

Bài tập

  1. Tạo DataFrame employees (id, name, age, salary, city). Lọc nhân viên có salary > 1000 age trong khoảng [25, 40]. Yêu cầu dùng đúng & và dấu ngoặc, không dùng and.
  2. Dùng isin để chỉ giữ các dòng có city thuộc ["HN", "HCM", "DN"]. Sau đó dùng ~ để in ra các dòng không thuộc 3 thành phố này.
  3. Dùng str.contains(r"^Nguyen", regex=True, na=False) để lọc các tên bắt đầu bằng Nguyen. Thay regex=True bằng str.startswith và so sánh kết quả.
  4. Viết cùng một bộ lọc ở bài 1 bằng df.query(); so sánh độ dài, kiểm tra (mask_df.values == query_df.values).all().
  5. (Mở rộng) Lọc outlier trên cột salary theo z-score: giữ lại các dòng có |z| < 3. So sánh shape trước và sau khi lọc.
14

Tóm tắt

  • Boolean mask = Series bool cùng index với DataFrame; df[mask] giữ lại row mà mask True.
  • Kết hợp điều kiện dùng &, |, ~ và bọc mỗi điều kiện trong dấu ngoặc; không dùng and/or/not trên Series.
  • Phương thức tiện: isin, between, isna, notna; chuỗi dùng .str.contains với na=False và tham số regex.
  • df.query("expr") SQL-like, đọc gọn cho điều kiện dài; tham chiếu biến với @var.
  • Gán giá trị có điều kiện qua df.loc[mask, col] = ...; tránh SettingWithCopyWarning bằng .loc hoặc .copy().
  • Filter không guarantee view hay copy — đọc thì OK, ghi thì luôn dùng .loc hoặc .copy().