1 Hướng dẫn thực hành

Để thực hành viết code theo tài liệu này cũng như để làm các bài tập, sử dụng Google Colab notebook sau đây: https://colab.research.google.com/drive/1b7Tb6MAacgKyviJF2TsMV3N18Ys8n4Nc?usp=sharing

2 Tổng quan về Numpy và Pandas

NumpyPandas là hai thư viện mạnh mẽ trong Python và được sử dụng rộng rãi trong phân tích dữ liệu.

Numpy (Numerical Python) là 1 thư viện đóng vai trò nền tảng cho việc tính toán số học trong Python. Nó cung cấp ndarray (mảng đa chiều) và các công cụ để làm việc với mảng này như thực hiện các phép tính toán số học trên mảng, xử lý ma trận, tạo các dãy số ngẫu nhiên và nhiều chức năng khác. Các tính toán được thực hiện trên toàn bộ mảng (thay vì phải dùng vòng lặp và thực hiện trên từng phần tử) nên tốc độ tính toán nhanh hơn là sử dụng Python list.

Pandas là 1 thư viện cung cấp các cấu trúc dữ liệu hiệu suất cao và các công cụ phân tích dữ liệu cho Python. Trong Pandas ta cần lưu ý 2 cấu trúc dữ liệu mà Python cơ bản không có, đó là SeriesDataFrame, cả hai cấu trúc này đều được xây dựng trên Numpy. Pandas rất hữu ích cho việc xử lý và phân tích dữ liệu có cấu trúc (dạng bảng, ví dụ như load từ bảng Excel hoặc SQL databases, …).

  • Series: Một series là 1 object dạng mảng 1 chiều và có index

  • DataFrame: DataFrame trong Pandas là cấu trúc dữ liệu hai chiều, nó giống như 1 bảng dữ liệu trong SQL, cũng có hàng và cột. Hàng và cột đều có index.

Ví dụ ta tạo DataFrame từ SeriesPython list như sau:

import numpy as np
import pandas as pd

cust_id_ndarray = np.array((1, 2, 3, 4, 5, 6, 7, 8, 9, 10))
cust_name_list = ["Nguyen Van A", "Nguyen Van B", "Le Thi C", "Le Thi D", "Le Thi E", "Nguyen Van F", "Le Thi G", "Le Thi H", "Le Thi I", "Le Thi J"]
cust_income_list = [50, 50, 600, 200, None, None, None, 100, None, None]
cust_vip_flag = [True, False, None, None, None, None, None, False, None, False]
customer_data_dict = {"customer_id":cust_id_ndarray,
                      "customer_name":cust_name_list,
                      "customer_income":cust_income_list,
                      "vip_flag":cust_vip_flag}
my_df = pd.DataFrame(customer_data_dict)
my_df
##    customer_id customer_name  customer_income vip_flag
## 0            1  Nguyen Van A             50.0     True
## 1            2  Nguyen Van B             50.0    False
## 2            3      Le Thi C            600.0     None
## 3            4      Le Thi D            200.0     None
## 4            5      Le Thi E              NaN     None
## 5            6  Nguyen Van F              NaN     None
## 6            7      Le Thi G              NaN     None
## 7            8      Le Thi H            100.0    False
## 8            9      Le Thi I              NaN     None
## 9           10      Le Thi J              NaN    False

Bài học này sẽ tập trung vào các nội dung kiến thức quan trọng trong sử dụng Pandas để biến đổi và xử lý dữ liệu.

3 Data profiling (kiểm tra sơ bộ dữ liệu) thông qua các attribute và method có sẵn

Cũng giống như khi sử dụng bảng trong SQL, trước khi sử dụng dữ liệu trong một Pandas DataFrame thì ta cũng cần kiểm tra dữ liệu trong DataFrame đó. Pandas DataFrame có sẵn các attribute và method giúp ta kiểm tra dữ liệu nhanh gọn và tiện lợi hơn khi sử dụng SQL.

Hiện nay đã có nhiều library giúp việc data profiling nhanh và tự động hơn, như Pandas Profiling, AutoViz, … tuy nhiên chúng sẽ không được đề cập đến trong khuôn khổ khoá học này vì chúng ta cần biết cách thực hiện data profiling với những công cụ có sẵn trong pandas để vừa hiểu rõ hơn cách pandas hoạt động và vừa chủ động hơn, không bị lệ thuộc vào thêm nhiều library khác (đề phòng môi trường làm việc được cài đặt tại cơ quan của bạn không cho phép cài đặt thêm các library này chẳng hạn).

3.1 DataFrame.head()

Method này giúp ta xem nhanh một vài dòng đầu tiên của DataFrame, mặc định là 5 dòng

my_df.head()
##    customer_id customer_name  customer_income vip_flag
## 0            1  Nguyen Van A             50.0     True
## 1            2  Nguyen Van B             50.0    False
## 2            3      Le Thi C            600.0     None
## 3            4      Le Thi D            200.0     None
## 4            5      Le Thi E              NaN     None

3.2 DataFrame.tail()

Method này giúp ta xem nhanh một vài dòng cuối cùng của DataFrame, mặc định là 5 dòng

my_df.tail(3)
##    customer_id customer_name  customer_income vip_flag
## 7            8      Le Thi H            100.0    False
## 8            9      Le Thi I              NaN     None
## 9           10      Le Thi J              NaN    False

3.3 DataFrame.dtypes

Attribute này giúp ta xem tên các cột và kiểu dữ liệu của chúng

my_df.dtypes
## customer_id          int64
## customer_name       object
## customer_income    float64
## vip_flag            object
## dtype: object

3.4 DataFrame.isnull() / DataFrame.isna()

2 method này thực chất là 1, và chúng kiểm tra tất cả các giá trị trong DataFrame có phải là giá trị trống hay không (missing values hay còn gọi là NA values)

# Kiểm tra các giá trị bị thiếu trên tất cả các cột
my_df.isnull().sum()
## customer_id        0
## customer_name      0
## customer_income    5
## vip_flag           6
## dtype: int64
# hoặc
my_df.isna().sum()
## customer_id        0
## customer_name      0
## customer_income    5
## vip_flag           6
## dtype: int64

3.5 DataFrame.shape

Attribute này giúp ta kiểm tra số dòng và số cột của bảng

my_df.shape
## (10, 4)

3.6 DataFrame.describe()

Method này giúp ta xem được các thông tin về thống kê mô tả (descriptive statistics) của dữ liệu trong tất cả các cột của bảng (ta có thể lựa chọn trả ra thông tin của tất cả các cột hoặc chỉ của các cột có kiểu dữ liệu nhất định).

my_df.describe(include="all")
##         customer_id customer_name  customer_income vip_flag
## count      10.00000            10         5.000000        4
## unique          NaN            10              NaN        2
## top             NaN  Nguyen Van A              NaN    False
## freq            NaN             1              NaN        3
## mean        5.50000           NaN       200.000000      NaN
## std         3.02765           NaN       231.840462      NaN
## min         1.00000           NaN        50.000000      NaN
## 25%         3.25000           NaN        50.000000      NaN
## 50%         5.50000           NaN       100.000000      NaN
## 75%         7.75000           NaN       200.000000      NaN
## max        10.00000           NaN       600.000000      NaN

Kết quả trả ra bao gồm các thông tin:

  • Với các cột dạng số (tất cả các chỉ số đều được tính toán sau khi loại bỏ các giá trị bị thiếu: None, NaN):

    • count: đếm số dòng

    • mean: giá trị trung bình

    • std: standard deviation (độ lệch chuẩn)

    • min: giá trị nhỏ nhất

    • 25%: giá trị của tứ phân vị thứ nhất (first quartile - Q1)

    • 50%: giá trị của trung vị (median)

    • 75%: giá trị của tứ phân vị thứ 3 (third quartile - Q3)

    • max: giá trị lớn nhất

  • Với các cột dạng không phải số:

    • count: đếm số dòng (không tính các giá trị None)

    • unique: số lượng các giá trị duy nhất (không trùng lặp) (không tính giá trị None)

    • top: giá trị xuất hiện nhiều nhất (không tính giá trị None)

    • freq: số lần xuất hiện của giá trị ở mục “top”

3.7 DataFrame.columns

Attribute này giúp ta kiểm tra tên các cột trong bảng

my_df.columns
## Index(['customer_id', 'customer_name', 'customer_income', 'vip_flag'], dtype='object')

3.8 Series.value_count()

Method này của Series giúp ta đếm số dòng của từng giá trị unique trong cột (tương tự count distinct trong SQL)

my_df["vip_flag"].value_counts(dropna=False)
## vip_flag
## None     6
## False    3
## True     1
## Name: count, dtype: int64

4 Axis

Khi làm việc với pandas DataFrame ta sẽ rất hay gặp khái niệm axis (vd: lệnh DataFrame.drop() có tham số axis nhận giá trị 0 hoặc 1, nếu nhận 0 thì thực hiện xoá dòng, nhận 1 thì thực hiện xoá cột, …). Ta chỉ cần nhìn vào hình minh hoạ dưới đây và nhớ rằng khi nói đến axis=0 thì là đang nói đến dòng, còn axis=1 là đang nói đến cột trong DataFrame

5 Inplace operation

Khi làm việc với Pandas DataFrame, thường các DataFrame methods sẽ biến đổi dữ liệu và tạo ra 1 DataFrame mới. Tuy nhiên với 1 số methods ta có thể khai báo parameter inplace=True để thực hiện biến đổi dữ liệu trên chính DataFrame đó. Việc thực hiện biến đổi ngay trên chính DataFrame (inplace) trên lý thuyết là để tránh việc tạo ra 1 bản copy nữa của DataFrame, làm cho việc biến đổi dữ liệu chậm hơn (vì phải copy data) và tốn thêm bộ nhớ (memory). Tuy nhiên nó tiềm ẩn rủi ro khiến ta xử lý biến đổi dữ liệu sai và không quay lại trạng thái lúc trước của DataFrame được mà phải biến đổi lại từ đầu. Ngoài ra, nhiều inplace operation thực tế vẫn ngầm tạo ra các bản copy của dữ liệu nên không giúp ích được gì cho việc tăng tốc độ xử lý. Vì vậy, ta hết sức lưu ý khi dùng các biến đổi inplace, và với người mới bắt đầu, hãy cố gắng tránh sử dụng.

6 Lọc dữ liệu trong DataFrame sử dụng loc và iloc

Best practice để lọc dữ liệu trong pandas là sử dụng 2 properties.loc.iloc. Chúng giúp ta lọc dữ liệu theo nhiều cách thức khác nhau và giúp code lọc dữ liệu trở nên rõ ràng hơn.

Dù dùng .loc hay .iloc thì thứ tự trong syntax sẽ luôn là điều kiện lọc dòng đứng trước rồi mới đến điều kiện lọc cột

6.1 .loc: lọc dữ liệu theo giá trị

6.1.1 lọc dòng theo điều kiện nhất định và lấy ra một vài cột nhất định

# lọc dữ liệu khách hàng có income < 200 hoặc income >= 600 
# và lấy ra 2 cột là customer_id và customer_income
my_df.loc[(my_df.customer_income < 200) | (my_df.customer_income >= 600), ["customer_id", "customer_income"]]
##    customer_id  customer_income
## 0            1             50.0
## 1            2             50.0
## 2            3            600.0
## 7            8            100.0

Lưu ý: khi sử dụng nhiều hơn 1 điều kiện lọc (ví dụ: A và B, A hoặc B, …) thì hãy luôn tuân theo best practice là bọc từng điều kiện lọc trong ngoặc đơn. Điều này giúp câu lệnh không bị lỗi do Python có thứ tự ưu tiên xử lý các toán tử (operator) nhất định, và nếu ta không để ý khi viết điều kiện thì rất dễ là thứ tự mà Python xử lý sẽ không giống với thứ tự mà ta mong muốn.

Ví dụ: hãy thử chạy câu lệnh trên nhưng không có ngoặc đơn để phân tách 2 điều kiện, câu lệnh sẽ báo lỗi. Lí do là vì thứ tự xử lý của Python sẽ là: điều kiện my_df.customer_income < 200, sau đó đến toán tử “|” (hoặc), rồi mới đến toán tử >=

Xem code dưới đây để hiểu rõ vì sao câu lệnh bị lỗi

# 2 câu lệnh sau đây sẽ bị lỗi giống hệt nhau

# lệnh 1
my_df.loc[my_df.customer_income < 200 | my_df.customer_income >= 600, ["customer_id", "customer_income"]]
## Cannot perform 'ror_' with a dtyped [float64] array and scalar of type [bool]
# lệnh 2
my_df.loc[(my_df.customer_income < 200 | my_df.customer_income) >= 600, ["customer_id", "customer_income"]]
## Cannot perform 'ror_' with a dtyped [float64] array and scalar of type [bool]

6.1.2 lọc dòng theo điều kiện nhất định và lấy ra tất cả các cột

# lọc dữ liệu khách hàng có income >= 100 và < 600 và lấy ra tất cả các cột
my_df.loc[(my_df.customer_income >= 100) & (my_df.customer_income < 600), :]
##    customer_id customer_name  customer_income vip_flag
## 3            4      Le Thi D            200.0     None
## 7            8      Le Thi H            100.0    False

6.1.3 lấy ra tất cả các dòng và một vài cột nhất định

my_df.loc[:, ["customer_id", "customer_income"]]
##    customer_id  customer_income
## 0            1             50.0
## 1            2             50.0
## 2            3            600.0
## 3            4            200.0
## 4            5              NaN
## 5            6              NaN
## 6            7              NaN
## 7            8            100.0
## 8            9              NaN
## 9           10              NaN

6.1.4 lấy ra các dòng theo giá trị của index và các cột liên tiếp

# tạo DataFrame my_df_2 giống hệt my_df nhưng không có index = 2 
my_df_2 = my_df.copy()
my_df_2 = my_df_2.set_index(pd.Index([0, 1, 3, 4, 5, 6, 7, 8, 9, 10]))
my_df_2
##     customer_id customer_name  customer_income vip_flag
## 0             1  Nguyen Van A             50.0     True
## 1             2  Nguyen Van B             50.0    False
## 3             3      Le Thi C            600.0     None
## 4             4      Le Thi D            200.0     None
## 5             5      Le Thi E              NaN     None
## 6             6  Nguyen Van F              NaN     None
## 7             7      Le Thi G              NaN     None
## 8             8      Le Thi H            100.0    False
## 9             9      Le Thi I              NaN     None
## 10           10      Le Thi J              NaN    False
# lọc dữ liệu của my_df_2, lấy ra các dòng có index với giá trị từ 1 đến 4 
# (bao gồm cả 1 và 4) và lấy các cột từ customer_id đến cột customer_income
my_df_2.loc[1:4, "customer_id":"customer_income"]
##    customer_id customer_name  customer_income
## 1            2  Nguyen Van B             50.0
## 3            3      Le Thi C            600.0
## 4            4      Le Thi D            200.0

Lưu ý: hãy nhớ rằng .loc giúp ta lọc dữ liệu theo giá trị của index chứ không phải theo vị trí của index, nên câu lệnh bên trên giúp ta lọc ra các dòng có index “với giá trị” trong khoảng từ 1 đến 4 (là các dòng có index bằng 1, 3, 4) chứ không phải là lọc ra các dòng với index có thứ tự số 1, 2, 3, 4 (tương ứng với các dòng có index là 1, 3, 4, 5 trong my_df_2)

6.2 .iloc: lọc dữ liệu theo vị trí

6.2.1 lọc dòng và cột theo index

# lọc lấy các dòng có index thứ tự từ 0 đến 1 
# (không lấy 2, giống cách slicing trong Numpy và trong Python list) 
# và lấy các cột có vị trí (index) từ 1 đến 2 (không lấy 3). 
# Cột customer_id là cột có index = 0
my_df_2.iloc[0:2, 1:3]
##   customer_name  customer_income
## 0  Nguyen Van A             50.0
## 1  Nguyen Van B             50.0
# lọc lấy các dòng có index thứ tự từ 0 đến 1 
# (không lấy 2, giống cách slicing trong Numpy và trong Python list) 
# và lấy các cột có index = 1 và = 3
my_df_2.iloc[0:2, [1, 3]]
##   customer_name vip_flag
## 0  Nguyen Van A     True
## 1  Nguyen Van B    False

6.3 Lọc dữ liệu nâng cao

Nhờ .loc và .iloc mà ta có thể lọc được dữ liệu rất linh hoạt và dễ dàng hơn nhiều so với khi sử dụng SQL như trong một số yêu cầu dưới đây

6.3.1 Lấy ra tất cả các cột chỉ trừ cột cuối

my_df.iloc[:,:-1]
##    customer_id customer_name  customer_income
## 0            1  Nguyen Van A             50.0
## 1            2  Nguyen Van B             50.0
## 2            3      Le Thi C            600.0
## 3            4      Le Thi D            200.0
## 4            5      Le Thi E              NaN
## 5            6  Nguyen Van F              NaN
## 6            7      Le Thi G              NaN
## 7            8      Le Thi H            100.0
## 8            9      Le Thi I              NaN
## 9           10      Le Thi J              NaN

6.3.2 Lấy ra tất cả các cột chỉ trừ một số cột nhất định

# lấy ra tất cả các cột chỉ trừ 2 cột là customer_name và vip_flag
my_df_2.loc[:, ~my_df_2.columns.isin(["customer_name", "vip_flag"])]
##     customer_id  customer_income
## 0             1             50.0
## 1             2             50.0
## 3             3            600.0
## 4             4            200.0
## 5             5              NaN
## 6             6              NaN
## 7             7              NaN
## 8             8            100.0
## 9             9              NaN
## 10           10              NaN

6.3.3 Lấy ra tất cả các cột với kiểu dữ liệu nhất định

# lấy ra tất cả các cột với kiểu dữ liệu dạng số (integer hay float đều là dạng số)
my_df_2.select_dtypes(include="number")
##     customer_id  customer_income
## 0             1             50.0
## 1             2             50.0
## 3             3            600.0
## 4             4            200.0
## 5             5              NaN
## 6             6              NaN
## 7             7              NaN
## 8             8            100.0
## 9             9              NaN
## 10           10              NaN

7 Nhóm dữ liệu và tính toán

Khi thực hiện nhóm dữ liệu và tính toán với pandas thì ta cần lưu ý những điểm sau đây:

7.1 Nên lọc dữ liệu trước khi nhóm lại

Tương tự như với SQL, ta nên lọc dữ liệu trước (ở mệnh đề WHERE của SQL) rồi mới nhóm dữ liệu (mệnh đề GROUP BY) khi có thể vì nó sẽ làm giảm số lượng bản ghi cần phải xử lý, từ đó làm tăng tốc độ xử lý của câu lệnh. Tuy rằng Python lưu dữ liệu vào memory (hay ta hiểu chính là vào RAM của máy tính) nên tốc độ xử lý nhìn chung là nhanh, nhưng điều này cũng dẫn tới hạn chế đó là nếu có quá nhiều dữ liệu được lưu vào memory sẽ có thể dẫn đến tình trạng tràn bộ nhớ, khiến Python bị crash và ta phải reset lại. Vì vậy, hãy luôn lọc dữ liệu để làm nhỏ bớt lượng dữ liệu trước khi xử lý nếu có thể.

7.2 Nhóm dữ liệu, tính toán, đặt lại tên cột

Có nhiều cách viết code để nhóm dữ liệu của DataFrame và tính toán, tuy nhiên cách sau đây là một trong những cách tiện lợi, dễ hiểu và có tính ứng dụng cao nhất. Đoạn code sau đây cũng thể hiện các hàm dùng để tính toán những chỉ số hay dùng khi groupby:

  • đếm số dòng: size

  • đếm số dòng nhưng loại trừ các giá trị bị thiếu (None, NaN, NA): count

  • đếm số lượng các giá trị unique: nunique

  • tính tổng: sum (tương tự là các hàm tính min, max, mean, …)

Lưu ý: tham số as_index mặc định nhận giá trị True, khi đó cột được sử dụng để làm key khi nhóm dữ liệu sẽ trở thành index của DataFrame. Ta set as_index = False khi muốn cột dùng làm key khi nhóm dữ liệu không trở thành index của DataFrame mà trở thành 1 cột bình thường trong DataFrame.

my_df_2.groupby(
  "vip_flag", as_index=False
).agg(
  row_count = ("customer_income", "size"),
  rn_cnt_not_none = ("customer_income", "count"),
  cnt_unique_not_none = ("customer_income", "nunique"),
  total_income = ("customer_income", "sum"),
).sort_values(
  by="total_income", ascending=True
)
##   vip_flag  row_count  rn_cnt_not_none  cnt_unique_not_none  total_income
## 1     True          1                1                    1          50.0
## 0    False          3                2                    2         150.0

8 Làm việc với dữ liệu ngày tháng và thời gian

8.1 Module datetime trong Python

Để làm việc với kiểu dữ liệu ngày tháng và thời gian trong Python thì ta sẽ cần biết sử dụng cơ bản module datetime.

Thường ta sẽ cần import Class datetime từ module datetime, viết lệnh như sau:

from datetime import datetime

Trong class datetime ta có một số kiểu dữ liệu chính như sau: date, time, datetime, timedelta, tzinfo, timezone

Đọc thêm tại đây: https://docs.python.org/3/library/datetime.html#

Ta xem ví dụ dưới đây hiểu được nhanh chóng và căn bản cách sử dụng các kiểu dữ liệu này

from datetime import datetime

# ta có date và datetime dưới dạng string
my_date_str = "2023-11-21"
my_datetime_str = "2023-12-01 05:03:42"

# convert string thành kiểu dữ liệu date và datetime
my_date = datetime.strptime(my_date_str, "%Y-%m-%d").date()
my_datetime = datetime.strptime(my_datetime_str, "%Y-%m-%d %H:%M:%S")

print(my_date)
## 2023-11-21
type(my_date)
## <class 'datetime.date'>
print(my_datetime)
## 2023-12-01 05:03:42
type(my_datetime)
## <class 'datetime.datetime'>

8.1.1 Tính toán với ngày tháng, thời gian

# tính khoảng cách giữa 2 ngày, kết quả trả ra là 1 timedelta object
print(my_datetime.date() - my_date)
## 10 days, 0:00:00
type(my_datetime.date() - my_date)
## <class 'datetime.timedelta'>
# tính khoảng cách giữa 2 datetime (ngày giờ), kết quả trả ra là 1 timedelta object
my_datetime - datetime.strptime("2023-11-21 22:13:25", "%Y-%m-%d %H:%M:%S")
## datetime.timedelta(days=9, seconds=24617)
type(my_datetime - datetime.strptime("2023-11-21 22:13:25", "%Y-%m-%d %H:%M:%S"))
## <class 'datetime.timedelta'>
# cộng, trừ ngày giờ bằng cách sử dụng timedelta object
from datetime import timedelta
my_datetime + timedelta(days=1, seconds=10)
## datetime.datetime(2023, 12, 2, 5, 3, 52)

8.2 Định dạng ngày tháng

Tương tự như đã đề cập tại khoá học Job Seekers, best practice là nên lưu và làm việc với dữ liệu ngày tháng và thời gian theo tiêu chuẩn ISO standard 8601 for date format.

8.3 Pandas - Chuyển đổi kiểu dữ liệu chữ sang kiểu thời gian

Thường ta sẽ load dữ liệu từ 1 nguồn nào đó vào pandas DataFrame, có thể là từ file .csv hoặc từ SQL table. Nếu pandas tự động gán cho các cột dạng thời gian kiểu dữ liệu object (tức là string) thì ta nên chuyển kiểu dữ liệu đó sang dạng thời gian (kiểu dữ liệu datetime64 của Numpy) để làm việc với các cột đó dễ dàng hơn. datetime64 sẽ là kiểu dữ liệu thời gian mà ta sử dụng khi làm việc với pandas dataframe. Đọc thêm tại đây: https://numpy.org/doc/stable/reference/arrays.datetime.html

Ví dụ: ta load dữ liệu từ file .csv và biến đổi cột dữ liệu từ dạng chữ sang dạng thời gian như sau

import requests
from bs4 import BeautifulSoup
import urllib

import pandas as pd
from datetime import datetime

# URL of file to be loaded
github_url = 'https://raw.githubusercontent.com/tuanphan92/datatute-datasets/main/JobSeeker/sakila%20dataset/sakila-db/sakila_film.csv'

# load file to pandas dataframe
df = pd.read_csv(github_url, encoding = "utf-8", sep=";")
df = df.loc[:, ["film_id", "last_update"]]
df.head()
##    film_id          last_update
## 0        1  2006-02-15 05:03:42
## 1        2  2006-02-15 05:03:42
## 2        3  2006-02-15 05:03:42
## 3        4  2006-02-15 05:03:42
## 4        5  2006-02-15 05:03:42
df.dtypes
## film_id         int64
## last_update    object
## dtype: object
# change last_update from object type to datetime type
df["last_update"] = pd.to_datetime(df["last_update"], format='%Y-%m-%d %H:%M:%S')
df.dtypes
## film_id                 int64
## last_update    datetime64[ns]
## dtype: object

8.4 Pandas - Các tính toán thường gặp với dữ liệu thời gian

Ta có bảng dữ liệu thời gian giao dịch ngân hàng như sau:

# URL of file to be loaded
github_url = "https://raw.githubusercontent.com/tuanphan92/datatute-datasets/main/data-analytics-booster/bank_transaction_table.csv"

# load file to pandas dataframe
df = pd.read_csv(github_url, encoding = "utf-8", sep=",")
df.head()
##    trans_id  cust_id  trans_timestamp  trans_amt_vnd trans_final_status
## 0         1    74452    1670307686000      178061487             failed
## 1         2    58217    1691936364000        4043095             failed
## 2         3    39259    1694704270000       80445236            success
## 3         4    28824    1681570267000      217118584            success
## 4         5    57355    1671758825000       38983836             failed

Ta thấy rằng cột trans_timestamp không ở dạng ngày giờ thông thường mà lại có dạng 1 chuỗi số. Đây là dạng “unix timestamp”, là dạng dữ liệu thời gian khá thường gặp (đọc thêm tại đây: https://www.unixtimestamp.com/). Ta cần convert về dạng datetime64 để tiện sử dụng trong pandas DataFrame.

Lưu ý: unix timestamp thường được lưu dưới dạng second (s), milisecond (ms) hoặc nanosecond (ns) nên ta cần kiểm tra tài liệu mô tả về dữ liệu mà ta đang sử dụng để biết xem dữ liệu được lưu dưới dạng nào, sau đó kiểm tra thử cho chắc chắn. Ví dụ dữ liệu của bảng giao dịch này là ở dạng milisecond. Trong câu lệnh dưới nếu bạn khai báo unit=“s” thì sẽ báo lỗi còn để mặc định là unit=“ns” thì sẽ trả ra kết quả sai (sẽ ra toàn là ngày 1970-01-01)

df["trans_timestamp"] = pd.to_datetime(df["trans_timestamp"], unit="ms", utc=True)
df
##      trans_id  cust_id  ... trans_amt_vnd  trans_final_status
## 0           1    74452  ...     178061487              failed
## 1           2    58217  ...       4043095              failed
## 2           3    39259  ...      80445236             success
## 3           4    28824  ...     217118584             success
## 4           5    57355  ...      38983836              failed
## ..        ...      ...  ...           ...                 ...
## 995       996    78329  ...     251891929             success
## 996       997    60573  ...     188692659             success
## 997       998      555  ...     220844250              failed
## 998       999     1144  ...     396626525              failed
## 999      1000    14855  ...     247175635             success
## 
## [1000 rows x 5 columns]

Yêu cầu:

  • Tạo cột “hours_from_now” để tính số giờ kể từ khi giao dịch được thực hiện cho tới thời điểm hiện tại (tương tự ta cũng có thể tính số ngày, tháng, năm, …).

  • Tạo cột “trans_year” để lưu năm mà giao dịch được thực hiện (tương tự ta cũng có thể lưu ngày, tháng, …)

Lưu ý: unix timestamp luôn thuộc time zone là UTC (vì bản chất nó là số giây tính từ 1970-01-01 lúc 00:00 UTC)

# tạo cột hours_from_now
df["hours_from_now"] = (pd.Timestamp.now(tz="UTC") - df["trans_timestamp"]) / pd.Timedelta(1, "h")

# tạo cột trans_year
df["trans_year"] = df["trans_timestamp"].dt.year
df[["trans_id", "trans_timestamp", "hours_from_now", "trans_year"]].head()
##    trans_id           trans_timestamp  hours_from_now  trans_year
## 0         1 2022-12-06 06:21:26+00:00    14683.731747        2022
## 1         2 2023-08-13 14:19:24+00:00     8675.765636        2023
## 2         3 2023-09-14 15:11:10+00:00     7906.902858        2023
## 3         4 2023-04-15 14:51:07+00:00    11555.237025        2023
## 4         5 2022-12-23 01:27:05+00:00    14280.637580        2022

8.5 Pandas - Time zones

Tương tự như đã đề cập tại khoá học Job Seekers, best practice là lưu dữ liệu về cùng 1 time zone, tốt nhất là UTC.

9 Tạo cột mới dựa trên điều kiện (if else)

Nếu những ai đã quen thuộc với SQL thì có thể hiểu nội dung này cũng giống như là tạo ra 1 cột mới sử dụng lệnh CASE trong SQL vậy

Khi làm việc với Pandas DataFrame thì có nhiều cách để tạo cột mới dựa trên điều kiện if else, tuy nhiên 2 cách sau đây là rõ ràng, tiện lợi nhất về cả về syntax, chức năng và performance (hiệu năng):

9.0.1 Cách 1: dùng np.where()

import numpy as np

df['trans_amt_group'] = np.where(df['trans_amt_vnd']<1e6, '0 - 1mil VND',
                   np.where((df['trans_amt_vnd']>=1e6) & (df['trans_amt_vnd']<5e7), '1 - 50mil VND',
                   np.where((df['trans_amt_vnd']>=5e7) & (df['trans_amt_vnd']<2e8), '50 - 200mil VND',
                   np.where(df['trans_amt_vnd']>=2e8, '200mil VND and above', 'NA'))))

df.loc[:, ["trans_id", "trans_amt_vnd", "trans_amt_group"]].head(10)
##    trans_id  trans_amt_vnd       trans_amt_group
## 0         1      178061487       50 - 200mil VND
## 1         2        4043095         1 - 50mil VND
## 2         3       80445236       50 - 200mil VND
## 3         4      217118584  200mil VND and above
## 4         5       38983836         1 - 50mil VND
## 5         6      317618334  200mil VND and above
## 6         7      358803602  200mil VND and above
## 7         8      487137306  200mil VND and above
## 8         9      291619443  200mil VND and above
## 9        10      421195030  200mil VND and above

9.0.2 Cách 2: dùng np.select()

import numpy as np

df["trans_amt_group"] = np.select(
  # conditon list
  [
    df['trans_amt_vnd']<1e6, 
    (df['trans_amt_vnd']>=1e6) & (df['trans_amt_vnd']<5e7), 
    (df['trans_amt_vnd']>=5e7) & (df['trans_amt_vnd']<2e8), 
    df['trans_amt_vnd']>=2e8
  ], \
  # choice list
  [
    '0 - 1mil VND', 
    '1 - 50mil VND', 
    '50 - 200mil VND', 
    '200mil VND and above'
  ], \
  #default value
  default='NA' 
)

df.loc[:, ["trans_id", "trans_amt_vnd", "trans_amt_group"]].head(10)
##    trans_id  trans_amt_vnd       trans_amt_group
## 0         1      178061487       50 - 200mil VND
## 1         2        4043095         1 - 50mil VND
## 2         3       80445236       50 - 200mil VND
## 3         4      217118584  200mil VND and above
## 4         5       38983836         1 - 50mil VND
## 5         6      317618334  200mil VND and above
## 6         7      358803602  200mil VND and above
## 7         8      487137306  200mil VND and above
## 8         9      291619443  200mil VND and above
## 9        10      421195030  200mil VND and above

10 JOIN các DataFrame

Để join các DataFrame lại với nhau (giống như join các bảng trong SQL) thì ta dùng hàm “merge” như sau. Hãy để ý kĩ cách khai báo cột dùng làm key để join cho mỗi DataFrame, và cách xử lý để phân biệt các cột bị trùng tên giữa 2 DataFrame

df1 = pd.DataFrame(
  {
    'id': ['1', '2', '3', '4', '5'],
    'feature1': ['A', 'B', 'C', 'D', 'E'],
    'feature2': ['F', 'G', 'H', 'I', 'J']
  }, 
  columns = ['id', 'feature1', 'feature2']
)

df1
##   id feature1 feature2
## 0  1        A        F
## 1  2        B        G
## 2  3        C        H
## 3  4        D        I
## 4  5        E        J
df2 = pd.DataFrame(
  {
    'id_col': ['2', '2', '3', '4', '7'],
    'feature2': ['K', 'L', 'M', 'N', 'O'],
    'feature4': ['P', 'Q', 'R', 'S', 'T']
  }, 
  columns = ['id_col', 'feature2', 'feature4']
)

df2
##   id_col feature2 feature4
## 0      2        K        P
## 1      2        L        Q
## 2      3        M        R
## 3      4        N        S
## 4      7        O        T
df_merged = pd.merge(
    df1,
    df2,
    left_on="id",
    right_on="id_col",
    # on="id" (dùng khi cột để join của 2 df cùng tên)
    how="outer", # cách join
    suffixes=("_df1", "_df2"), # đưa thêm hậu tố vào tên cột để phân biệt các cột trùng tên sau khi join 
    validate="one_to_many" # kiểm tra key được dùng để join ở df1 có unique hay không
)

df_merged
##     id feature1 feature2_df1 id_col feature2_df2 feature4
## 0    1        A            F    NaN          NaN      NaN
## 1    2        B            G      2            K        P
## 2    2        B            G      2            L        Q
## 3    3        C            H      3            M        R
## 4    4        D            I      4            N        S
## 5    5        E            J    NaN          NaN      NaN
## 6  NaN      NaN          NaN      7            O        T

10.1 Lưu ý 1 - sử dụng tham số “validate” trong hàm merge:

Pandas đã cung cấp cho ta 1 công cụ rất tiện lợi khi join các DataFrame đó là tham số “validate” trong hàm merge giúp kiểm tra mối quan hệ giữa 2 bảng khi join, giúp ta đảm bảo kết quả join trả ra đúng với những gì ta muốn. Ví dụ, nếu kiểm tra mối quan hệ “one_to_many” thì pandas sẽ kiểm tra cột dùng làm key để join ở bảng bên trái có các giá trị đều duy nhất hay không, nếu không thì sẽ báo lỗi. Tuy không thể nói đây là best practice, nhưng với người mới bắt đầu, để tránh sai sót khi join các DataFrame, hãy tận dụng tính năng này để có thêm 1 chốt kiểm tra xem 2 DataFrame được join với nhau có thực sự là có những quan hệ “one_to_many”, “many_to_one”, và “one_to_one” hay không.

10.2 Lưu ý 2 - xử lý các cột bị trùng tên khi join DataFrame:

Khi join 2 pandas DataFrame, kết quả trả ra sẽ là tất cả các cột ở trong 2 DataFrame đó. Nếu cột được dùng làm key để join có tên giống nhau trong 2 DataFrame thì chỉ 1 cột xuất hiện trong kết quả trả ra, còn với các cột mà không phải là key để join, nếu trùng tên thì sẽ được tự động gắn hậu tố (suffix) là “_x” với bảng bên trái và “_y” với bảng bên phải.

Để xử lý các cột bị trùng tên dẫn tới có các hậu tố “_x” và “_y”, ta làm theo 1 trong các cách sau:

  1. Lọc lấy ra các cột cần thiết ở mỗi DataFrame trước khi join, đổi tên cột dùng làm key join cho giống nhau (hoặc sau khi join thì drop bớt 1 cột đi). Như vậy kết quả trả ra sẽ không thừa cột.
  2. Nếu ta biết chắc chắn rằng trong trường hợp trùng tên cột, ta sẽ luôn lấy cột ở bảng trái hoặc luôn lấy cột ở bảng phải thì ta có thể đặt suffix cho chỉ bảng phải hoặc bảng trái, rồi xoá các cột có suffix đó đi. Ví dụ luôn lấy cột ở bảng trái:
# chỉ lấy các cột ở bảng bên trái nếu cột trùng tên
df_merged_2 = pd.merge(
    df1,
    df2.rename(columns={"id_col":"id"}),
    on="id",
    how="outer",
    suffixes=(None, "_duplicated"), # đưa thêm hậu tố vào tên cột ở bảng bên phải
    validate="one_to_many"
).loc[:, lambda x: ~x.columns.str.endswith('_duplicated')] # chỉ lấy các cột không có hậu tố _duplicated

df_merged_2
##   id feature1 feature2 feature4
## 0  1        A        F      NaN
## 1  2        B        G        P
## 2  2        B        G        Q
## 3  3        C        H        R
## 4  4        D        I        S
## 5  5        E        J      NaN
## 6  7      NaN      NaN        T

10.3 Lưu ý 3 - không nên sử dụng method DataFrame.join():

Cho dù theo lý thuyết thì method này có thể giúp tăng performance của việc join 2 DataFrame, tuy nhiên để dùng method này ta phải chuyển hết tất cả các cột dùng làm key để join thành index. Như vậy ta dễ nhầm lẫn khi viết code, và cũng làm code khó hiểu hơn. Ngoài ra, để performance gain nhận thấy rõ rệt được thì các DataFrame phải rất lớn, mà nếu chúng đã rất lớn thì bạn cũng không nên thuần tuý chỉ dùng pandas để xử lý, mà nên tiền xử lý (có thể là với SQL) trước, rồi dùng pandas, hoặc sử dụng một processing engine khác như Spark chẳng hạn. Vì vậy, ta nên tránh sử dụng method .join() này. Ví dụ nếu join df1 và df2 bên trên sử dụng method này sẽ như sau:

# sử dụng DataFrame.join() để join các DataFrame thay vì pd.merge làm câu lệnh phức tạp và khó hiểu hơn
df_merged_using_join = df1\
  .set_index("id")\
  .join(
    df2.set_index("id_col"),
    on="id",
    how="outer",
    lsuffix="_df1",
    rsuffix="_df2"
  ).reset_index(drop=True)

df_merged_using_join
##   id feature1 feature2_df1 feature2_df2 feature4
## 0  1        A            F          NaN      NaN
## 1  2        B            G            K        P
## 2  2        B            G            L        Q
## 3  3        C            H            M        R
## 4  4        D            I            N        S
## 5  5        E            J          NaN      NaN
## 6  7      NaN          NaN            O        T

11 SQL WINDOW function trong pandas

Với những ai đã quen với việc sử dụng window function trong SQL thì khi chuyển sang sử dụng pandas sẽ không tránh khỏi bỡ ngỡ khi không tìm thấy các hàm rất tiện lợi như ROW_NUMBER(), LEAD(), LAG(), … Tuy nhiên, trong pandas ta hoàn toàn có thể thực hiện được các window function như trong SQL bằng cách sử dụng combo 2 method là DataFrame.sort_valuesDataFrame.groupby

11.1 Ví dụ 1: đánh số dòng để tìm dòng mới nhất hoặc cũ nhất - tương đương ROW_NUMBER() trong SQL

Có bảng customer_phone như sau

data = {
    "customer_id":[1, 1, 2, 3, 3, 3],
    "phone":["0913266654", "0984331988", "0943524647", "0942221817", "0942464764", "0915298756"],
    "source":["CRM", "Mobile App Form", "CRM", "Mobile App Form", "Mobile App Form", "CRM"],
    "updated_date":["2023-06-01", "2021-07-12", "2019-12-24", "2023-06-29", "2022-02-08", "2023-01-29"]
}

customer_phone_df = pd.DataFrame(data)

customer_phone_df
##    customer_id       phone           source updated_date
## 0            1  0913266654              CRM   2023-06-01
## 1            1  0984331988  Mobile App Form   2021-07-12
## 2            2  0943524647              CRM   2019-12-24
## 3            3  0942221817  Mobile App Form   2023-06-29
## 4            3  0942464764  Mobile App Form   2022-02-08
## 5            3  0915298756              CRM   2023-01-29

Yêu cầu: lấy ra dữ liệu số điện thoại mới nhất của từng khách hàng dựa theo thời gian cập nhật trên hệ thống

# đánh số dòng - tương đương ROW_NUMBER() trong SQL
customer_phone_df["rn"] = customer_phone_df\
  .sort_values(by="updated_date", ascending=True)\
  .groupby("customer_id")\
  .cumcount()+1 
  # do hàm cumcount sẽ đếm từ 0 đến độ dài của Series -1, nên ta phải + 1 vào 
  # để ra kết quả giống ROW_NUMBER

customer_phone_df
##    customer_id       phone           source updated_date  rn
## 0            1  0913266654              CRM   2023-06-01   2
## 1            1  0984331988  Mobile App Form   2021-07-12   1
## 2            2  0943524647              CRM   2019-12-24   1
## 3            3  0942221817  Mobile App Form   2023-06-29   3
## 4            3  0942464764  Mobile App Form   2022-02-08   1
## 5            3  0915298756              CRM   2023-01-29   2

11.2 Ví dụ 2: tìm giá trị của dòng liền sau hoặc liền trước - tương đương LEAD(), LAG() trong SQL

Bài tập: có bảng google_analytics_pageview thể hiện các trang mà người dùng (user) đã xem trên một website (chính là bảng google_analytics_pageview ở Lesson 2 khoá học Job Seekers).

data = {
    "session_id":[1, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3],
    "user":[
      "thang@mail.com", "thang@mail.com", "thang@mail.com", "thang@mail.com", "thang@mail.com", "thang@mail.com",
      "huyen@gmail.com", "huyen@gmail.com", "huyen@gmail.com",
      "thang@mail.com", "thang@mail.com", "thang@mail.com", "thang@mail.com"
    ],
    "time_stamp":[
        "2023-01-15 12:28:30", "2023-01-15 12:28:40", "2023-01-15 12:29:56", "2023-01-15 12:41:02", "2023-01-15 12:42:40", "2023-01-15 12:44:04",
        "2023-01-15 12:33:08", "2023-01-15 12:33:15", "2023-01-15 12:38:30",
        "2023-01-16 09:05:31", "2023-01-16 09:05:35", "2023-01-16 09:06:01", "2023-01-16 09:25:03"
    ],
    "page_url":[
        "https://datatute.vn/", "https://datatute.vn/about/", "https://datatute.vn/portfolio/",
        "https://datatute.vn/portfolio/job-seekers-course/", "https://datatute.vn/about/", "https://datatute.vn/portfolio/job-seekers-course/",
        "https://datatute.vn/portfolio/", "https://datatute.vn/portfolio/interview-training/", "https://datatute.vn/portfolio/free-cv-review/",
        "https://datatute.vn/", "https://datatute.vn/blog/", "https://datatute.vn/sql-ve-viec-tim-kiem-thong-tin-chua-xay-ra/", "https://datatute.vn/contact/"
    ]
}

google_analytics_pageview = pd.DataFrame(data)

# convert string to datetime
google_analytics_pageview["time_stamp"] = pd.to_datetime(
    google_analytics_pageview["time_stamp"], format='%Y-%m-%d %H:%M:%S'
)

Ta có bảng google_analytics_sessions thể hiện các session mà người dùng đã thực hiện trên website. 1 session có thể hiểu là 1 lần người dùng truy cập vào website, từ lúc mở website cho đến khi tắt trình duyệt web đi. Trong 1 session thì người dùng có thể truy cập và xem nhiều trang của website.

data = {
    "session_id":[1, 2, 3],
    "session_start":["2023-01-15 12:28:27", "2023-01-15 12:33:08", "2023-01-16 09:05:29"],
    "session_end":["2023-01-15 12:45:23", "2023-01-15 12:42:11", "2023-01-16 09:28:51"]
}

google_analytics_sessions = pd.DataFrame(data)

# convert string to datetime
google_analytics_sessions["session_start"] = pd.to_datetime(
    google_analytics_sessions["session_start"], format='%Y-%m-%d %H:%M:%S'
)

google_analytics_sessions["session_end"] = pd.to_datetime(
    google_analytics_sessions["session_end"], format='%Y-%m-%d %H:%M:%S'
)

Yêu cầu: Tính thời gian (bằng số giây) của mỗi lần khách hàng xem 1 trang (page) trên website. Để cho bài toán đỡ phức tạp, ta giả sử khách hàng chỉ xem 1 trang tại 1 thời điểm, và xem xong trang này mới chuyển sang trang khác (tức là không mở nhiều trang cùng 1 lúc).

Hãy thử tự làm trước khi xem đáp án. Gợi ý sử dụng method sort_values() kết hợp groupby() như ví dụ 1, nhưng thay method cumcount() bằng method shift(). Có thể tham khảo đáp án bằng SQL ở Lesson 2 khoá học Job Seekers và dùng pandas để triển khai lại đáp án SQL.

Đáp án:

# join bảng google_analytics_pageview với bảng google_analytics_sessions để lấy cột session_end
# page_start_time chính là cột time_stamp
page_df = pd.merge(
    google_analytics_pageview,
    google_analytics_sessions,
    on="session_id",
    how="left",
    validate="many_to_one"
).rename(columns={"time_stamp":"page_start_time"})

# tạo cột page_end_time lưu giá trị thời gian kết thúc của pageview
# sử dụng hàm shift(-1) kết hợp với sort_values() và groupby() 
# để tạo kết quả giống hàm LEAD() trong SQL
page_df["page_end_time"] = (
  page_df.sort_values(
      by="page_start_time", ascending=True
  ).groupby(
      "session_id"
  )["page_start_time"]
  .shift(-1)
)

# pageview cuối cùng trong session sẽ có page_end_time = session_end
page_df["page_end_time"] = (
    np.where(
        page_df['page_end_time'].isnull(), 
        page_df["session_end"],
        page_df['page_end_time']
    )
)

# tính time_on_page bằng page_end_time - page_start_time (đổi ra đơn vị giây)
page_df["time_on_page"] = (page_df["page_end_time"] - page_df["page_start_time"]).dt.seconds

12 Reshape data

Trong thực tế công việc ta thường gặp những tình huống (đặc biệt là để phục vụ phân tích hoặc vẽ biểu đồ) mà ta cần biến đổi dữ liệu từ dạng wide về dạng long và ngược lại. Việc biến đổi này gọi là “reshape data”.

Ví dụ 1: Ta thường gặp dữ liệu gốc ở định dạng sau (đặc biệt là nếu dữ liệu gốc được cung cấp từ 1 file excel hay google spreadsheet)

data = {
    "Item":["Revenue", "Cost"],
    "FY2019":[2850000, 2450000],
    "FY2020":[2680000, 2150000],
    "FY2021":[2775000, 2285000],
    "FY2022":[3001000, 2785000]
}

revenue_and_cost_df = pd.DataFrame(data)

revenue_and_cost_df
##       Item   FY2019   FY2020   FY2021   FY2022
## 0  Revenue  2850000  2680000  2775000  3001000
## 1     Cost  2450000  2150000  2285000  2785000

Dữ liệu ở dạng này gọi là dạng “wide”. Để thuận tiện cho việc phân tích cũng như vẽ đồ thị thì ta thường cần chuyển nó về dạng “long”. Pandas có sẵn hàm để ta chuyển qua lại giữa 2 dạng “wide”“long” rất thuận tiện.

Chuyển về dạng “long”: dùng hàm pd.melt()

# convert from wide to long
long_df = pd.melt(
  revenue_and_cost_df, 
  # cột/các cột được dùng làm id:
  id_vars=["Item"],
  # các cột sẽ được chuyển vào thành 2 cột "variable" và "value"":
  # trong trường hợp này là tất cả các cột còn lại trừ cột "Item
  value_vars=revenue_and_cost_df.columns.drop("Item") 
).rename(columns={"variable":"Year", "value":"Value"}) # đổi tên cột

long_df
##       Item    Year    Value
## 0  Revenue  FY2019  2850000
## 1     Cost  FY2019  2450000
## 2  Revenue  FY2020  2680000
## 3     Cost  FY2020  2150000
## 4  Revenue  FY2021  2775000
## 5     Cost  FY2021  2285000
## 6  Revenue  FY2022  3001000
## 7     Cost  FY2022  2785000

Ví dụ 2: chuyển từ dạng “long” về dạng “wide”

# convert from long to wide
wide_df = (
    long_df.pivot(
        index="Item", 
        columns="Year", 
        values="Value"
    )
).rename_axis( # bỏ tên (Year) của trục nằm ngang (Year, FY2019, FY2020, ...)
    None, 
    axis=1
).reset_index() # reset lại index để trở về dạng DataFrame thông thường

wide_df
##       Item   FY2019   FY2020   FY2021   FY2022
## 0     Cost  2450000  2150000  2285000  2785000
## 1  Revenue  2850000  2680000  2775000  3001000

13 Xử lý các giá trị trống/missing values

Nếu bạn là người thường xuyên sử dụng numpy và pandas làm công cụ chính cho việc xử lý dữ liệu hàng ngày của mình thì tốt nhất nên đọc pandas documentation và nắm thật vững về các kiểu dữ liệu mà pandas cung cấp. Đọc tại đây: https://pandas.pydata.org/docs/reference/arrays.html

Vì pandas sử dụng kiểu dữ liệu trong numpy cho một số kiểu dữ liệu và mở rộng type system của numpy cho một số kiểu dữ liệu khác, nên trong phạm vi khoá học này không thể (và có lẽ cũng chưa cần thiết với người mới bắt đầu) giải thích chi tiết hết được. Ngoài ra, tài liệu chính thức của pandas (link bên trên) là đầy đủ và chính xác.

13.1 Các loại giá trị trống thường gặp khi sử dụng Pandas

Khi load và xử lý dữ liệu bằng Pandas ta sẽ thường gặp các loại giá trị trống sau đây:

  • None: đây là kiểu giá trị trống của Python, thường xuất hiện ở các cột trong pandas DataFrame có data type = object

  • np.nan: đây là kiểu giá trị trống của numpy

  • pd.NaT: đây là kiểu giá trị trống với dữ liệu thời gian của pandas

  • pd.NA: đây là kiểu giá trị trống mới được đưa vào từ pandas 1.0 với mục đích để có 1 kiểu giá trị trống duy nhất cho tất cả các datatypes thay vì chỗ thì dùng np.nan, chỗ thì dùng pd.NaT, chỗ thì dùng None, …

Tuỳ vào datatype của Series (cột trong pandas DataFrame) mà sẽ có kiểu dữ liệu trống được sử dụng tương ứng. Hãy xem các câu lệnh dưới đây và kết quả của chúng để hiểu rõ hơn vấn đề này.

# cách thể hiện missing value của kiểu dữ liệu boolean

# Kiểu dữ liệu này chỉ cho phép nhận giá trị True và False
# nên khi ta truyền missing value None và np.nan vào thì chúng sẽ bị convert
# sang True hoặc False tương ứng (để ý là cách chúng bị convert ngược nhau)

# Nếu ta truyền pd.NA vào sẽ bị báo lỗi

s = pd.Series([1, 0, None, np.nan, ""], dtype="bool")
s
## 0     True
## 1    False
## 2    False
## 3     True
## 4    False
## dtype: bool
# cách thể hiện missing value của kiểu dữ liệu BooleanDtype

# Kiểu dữ liệu này cho phép nhận missing value
# và thể hiện dưới dạng pd.NA

s = pd.Series([1, 0, None, np.nan, pd.NA], dtype=pd.BooleanDtype())
s
## 0     True
## 1    False
## 2     <NA>
## 3     <NA>
## 4     <NA>
## dtype: boolean
# cách thể hiện missing value của kiểu dữ liệu integer

# Ta thấy lệnh báo lỗi rằng kiểu dữ liệu này không cho phép nhận missing value
# --> vậy nếu ta thấy cột nào trong pandas DataFrame có kiểu dữ liệu int64 
# thì chắc chắn cột đó không có missing value

# Nếu thay vì None và pd.NA, ta truyền vào giá trị np.nan thì kiểu dữ liệu của Series sẽ
# tự động bị convert sang float64 thay vì int64 (vì np.nan chỉ sử dụng được với kiểu float)

s = pd.Series([1, 0, None, pd.NA], dtype="int64")
## int() argument must be a string, a bytes-like object or a number, not
## 'NoneType'
# cách thể hiện missing value của kiểu dữ liệu Int64Dtype

# Kiểu dữ liệu này cho phép nhận missing value và thể hiện dưới dạng pd.NA
# Vì vậy, nếu trong pandas DataFrame cần có cột integer mà chứa được missing value
# thì ta nên dùng kiểu pd.Int64Dtype()

s = pd.Series([1, 0, None, np.nan, pd.NA], dtype=pd.Int64Dtype())
s
## 0       1
## 1       0
## 2    <NA>
## 3    <NA>
## 4    <NA>
## dtype: Int64
# cách thể hiện missing value của kiểu dữ liệu float

# Kiểu dữ liệu này cho phép nhận missing value
# và thể hiện dưới dạng np.nan
# (giá trị None đã tự động bị convert sang np.nan)

# Lệnh sẽ báo lỗi nếu ta truyền vào pd.NA

s = pd.Series([1, 0.2, None, np.nan], dtype="float64")
s
## 0    1.0
## 1    0.2
## 2    NaN
## 3    NaN
## dtype: float64
# cách thể hiện missing value của kiểu dữ liệu Float64Dtype

# Kiểu dữ liệu này cho phép nhận missing value 
# và thể hiện dưới dạng pd.NA

s = pd.Series([1, 0.2, None, np.nan, pd.NA], dtype=pd.Float64Dtype())
s
## 0     1.0
## 1     0.2
## 2    <NA>
## 3    <NA>
## 4    <NA>
## dtype: Float64
# cách thể hiện missing value của kiểu dữ liệu object

# Kiểu dữ liệu này cho phép nhận missing value ở bất kỳ kiểu nào ta muốn
# và giữ nguyên các kiểu dữ liệu missing value được truyền vào

s = pd.Series(["nguyen van a", [], None, np.nan, pd.NA], dtype="object")
s
## 0    nguyen van a
## 1              []
## 2            None
## 3             NaN
## 4            <NA>
## dtype: object
# cách thể hiện missing value của kiểu dữ liệu string

# Kiểu dữ liệu này cho phép nhận missing value
# và thể hiện dưới dạng pd.NA

s = pd.Series(["nguyen van a", "blabla", None, np.nan, pd.NA], dtype="string")
s
## 0    nguyen van a
## 1          blabla
## 2            <NA>
## 3            <NA>
## 4            <NA>
## dtype: string

13.2 Các lưu ý giống như trong SQL

xem Lesson 2 khoá Job Seekers, mục Xử lý các giá trị NULL, các phần sau vẫn đúng khi join (hàm merge) trong pandas:

  • giá trị NULL ở điều kiện lọc dữ liệu

  • giá trị NULL ở lệnh not in

  • giá trị NULL khi concatenate string

  • giá trị NULL khi sử dụng các phép tính toán học

  • giá trị NULL khi sử dụng các hàm aggregate

  • giá trị NULL khi INNER JOIN

  • giá trị NULL khi dùng điều kiện if else (np.where, …)

13.3 Thay thế các giá trị trống

Pandas cung cấp các công cụ mạnh mẽ cho ta trong việc thay thế các missing values. Ta lưu ý việc này thường chỉ dùng khi xử lý dữ liệu chuẩn bị cho train model, còn trong phân tích, đặc biệt là trong báo cáo cần rà soát số liệu chi tiết, phản ánh đúng số liệu, … thì nên hạn chế tối đa việc thay thế các giá trị trống, nhất là khi chưa hiểu rõ nguyên nhân dữ liệu bị thiếu/trống để tránh đưa ra kết quả phân tích sai hoặc người dùng báo cáo hiểu sai bản chất vấn đề.

Trong phạm vi khoá học này, ta sẽ chỉ cùng nhau review một số hàm cơ bản trong pandas và cách thay thế các giá trị trống cơ bản như sau:

13.3.1 Series.fillna và DataFrame.fillna()

Ta dùng 2 method này để thay thế các giá trị trống trong một hoặc nhiều cột hoặc trong toàn bộ DataFrame.

Một số cách thay thế giá trị trống thường gặp:

13.3.2 Thay thế bằng một giá trị cụ thể nhất dịnh

Trong những trường hợp ta hiểu rõ bản chất của việc thiếu dữ liệu, ta có thể chọn thay thế các giá trị trống bằng những giá trị cũng có ý nghĩa thể hiện việc không có dữ liệu. Ví dụ:

  • Với cột kiểu số (ví dụ giá trị hợp đồng chẳng hạn), ta có thể thay giá trị trống bằng số 0 để dữ liệu thể hiện ra đẹp hơn (và phần nào đó là tiện cho các thao tác tính toán hơn) nếu ta biết chắc chắn rằng đó là loại hợp đồng không có giá trị tiền chứ không phải là lỗi dữ liệu

  • Với cột kiểu chữ (text, string) ta có thể thay giá trị trống bằng giá trị “” để khi hiển thị dữ liệu trông sẽ giống như một ô trắng tinh, và ngoài ra tránh một số trường hợp phải viết code quá phức tạp khi thực hiện các xử lý trên string do có giá trị trống (missing value)

Xem ví dụ với bảng dữ liệu sau đây:

data = {
    "float_col":pd.Series([np.nan, 1.2, np.nan, 1.4, np.nan, 1.5], dtype="float64"),
    "string_col":pd.Series(["str1", pd.NA, "str3", np.nan, None, "str6"], dtype="string"),
    "object_col":pd.Series(["obj1", pd.NA, "obj3", np.nan, None, "obj6"], dtype="object"),
    "datetime_col":pd.Series(["2023-06-01", "2021-07-12", "2019-12-24", "2023-06-29", "2022-02-08", pd.NaT], dtype="datetime64[ns]")
}

df = pd.DataFrame(data)
df
##    float_col string_col object_col datetime_col
## 0        NaN       str1       obj1   2023-06-01
## 1        1.2       <NA>       <NA>   2021-07-12
## 2        NaN       str3       obj3   2019-12-24
## 3        1.4       <NA>        NaN   2023-06-29
## 4        NaN       <NA>       None   2022-02-08
## 5        1.5       str6       obj6          NaT
# fill missing values bằng giá trị 0
df["float_col"].fillna(0, inplace=False)
## 0    0.0
## 1    1.2
## 2    0.0
## 3    1.4
## 4    0.0
## 5    1.5
## Name: float_col, dtype: float64
# fill missing values cột string bằng giá trị ""
df2 = df.copy()
df2["string_col"].fillna("", inplace=True)
df2
##    float_col string_col object_col datetime_col
## 0        NaN       str1       obj1   2023-06-01
## 1        1.2                  <NA>   2021-07-12
## 2        NaN       str3       obj3   2019-12-24
## 3        1.4                   NaN   2023-06-29
## 4        NaN                  None   2022-02-08
## 5        1.5       str6       obj6          NaT

13.3.3 Thay thế bằng giá trị mean hoặc median

Đây là 2 cách thay thế giá trị trống rất phổ biến cho các cột dạng số để chuẩn bị dữ liệu cho việc training các mô hình machine learning. Median thường được dùng thay cho mean khi mà dữ liệu của cột đó có các outliers.

# fill with mean
df2 = df.copy()
df2["float_col"].fillna(df2["float_col"].mean(), inplace=True)
df2
##    float_col string_col object_col datetime_col
## 0   1.366667       str1       obj1   2023-06-01
## 1   1.200000       <NA>       <NA>   2021-07-12
## 2   1.366667       str3       obj3   2019-12-24
## 3   1.400000       <NA>        NaN   2023-06-29
## 4   1.366667       <NA>       None   2022-02-08
## 5   1.500000       str6       obj6          NaT
# fill with median
df2 = df.copy()
df2["float_col"].fillna(df2["float_col"].median(), inplace=True)
df2
##    float_col string_col object_col datetime_col
## 0        1.4       str1       obj1   2023-06-01
## 1        1.2       <NA>       <NA>   2021-07-12
## 2        1.4       str3       obj3   2019-12-24
## 3        1.4       <NA>        NaN   2023-06-29
## 4        1.4       <NA>       None   2022-02-08
## 5        1.5       str6       obj6          NaT

13.3.4 Thay thế bằng giá trị mode

Mode là giá trị xuất hiện nhiều nhất. Ta thường dùng mode để thay thế các giá trị trống với các cột thuộc kiểu dữ liệu categorical.

Ví dụ có dữ liệu gender như sau:

gender_series = pd.Series(["M", "F", "F", pd.NA, "F", "M", pd.NA])
gender_series
## 0       M
## 1       F
## 2       F
## 3    <NA>
## 4       F
## 5       M
## 6    <NA>
## dtype: object

Thay thế giá trị trống bằng mode

gender_series.fillna(gender_series.mode()[0], inplace=False)
## 0    M
## 1    F
## 2    F
## 3    F
## 4    F
## 5    M
## 6    F
## dtype: object

13.3.5 Thay thế bằng giá trị liền trước hoặc liền sau (forward fill, backward fill)

Cách thay thế giá trị trống này thường được dùng trong một số bài toán liên quan tới dữ liệu time series.

# forward-fill
df.fillna(method='ffill', inplace=False)
##    float_col string_col object_col datetime_col
## 0        NaN       str1       obj1   2023-06-01
## 1        1.2       str1       obj1   2021-07-12
## 2        1.2       str3       obj3   2019-12-24
## 3        1.4       str3       obj3   2023-06-29
## 4        1.4       str3       obj3   2022-02-08
## 5        1.5       str6       obj6   2022-02-08
# backward-fill
df.fillna(method='bfill', inplace=False)
##    float_col string_col object_col datetime_col
## 0        1.2       str1       obj1   2023-06-01
## 1        1.2       str3       obj3   2021-07-12
## 2        1.4       str3       obj3   2019-12-24
## 3        1.4       str6       obj6   2023-06-29
## 4        1.5       str6       obj6   2022-02-08
## 5        1.5       str6       obj6          NaT

13.3.6 DataFrame.dropna()

Trong một số ít trường hợp, ta muốn xoá những dòng mà có giá trị trống (trống ở cột nào không quan trọng), ta có thể làm như sau:

# trong trường hợp này, dòng nào của df cũng có missing value
# (không missing ở cột này thì ở cột khác)
# nên kết quả là tất cả các dòng ở df đều bị xoã
df.dropna(axis=0, inplace=False)
## Empty DataFrame
## Columns: [float_col, string_col, object_col, datetime_col]
## Index: []

13.3.7 DataFrame.drop()

Method này có thể được dùng để xoá cột hoặc dòng. Thường khi ta có những cột mà toàn bộ cột đều không có dữ liệu, ta nên xoá toàn bộ cột đi trước khi thực hiện các bước xử lý, phân tích tiếp theo.

# tạo một cột trống toàn bộ
df["empty_col"] = pd.Series([pd.NA, pd.NA, pd.NA, pd.NA, pd.NA, pd.NA], dtype="string")
df
##    float_col string_col object_col datetime_col empty_col
## 0        NaN       str1       obj1   2023-06-01      <NA>
## 1        1.2       <NA>       <NA>   2021-07-12      <NA>
## 2        NaN       str3       obj3   2019-12-24      <NA>
## 3        1.4       <NA>        NaN   2023-06-29      <NA>
## 4        NaN       <NA>       None   2022-02-08      <NA>
## 5        1.5       str6       obj6          NaT      <NA>
# xoá cột trống toàn bộ đi
# axis=1 để lệnh thực hiện xoá cột thay vì xoá dòng
df.drop("empty_col", axis=1, inplace=True)
df
##    float_col string_col object_col datetime_col
## 0        NaN       str1       obj1   2023-06-01
## 1        1.2       <NA>       <NA>   2021-07-12
## 2        NaN       str3       obj3   2019-12-24
## 3        1.4       <NA>        NaN   2023-06-29
## 4        NaN       <NA>       None   2022-02-08
## 5        1.5       str6       obj6          NaT

14 Python style guide

Khi viết code Python ta nên tuân thủ theo PEP-8. Đây là một bộ quy chuẩn và hướng dẫn để viết code Python sao cho dễ hiểu và đồng nhất (consistent).

Đọc bản full tại đây: https://peps.python.org/pep-0008/

Hoặc đọc bài viết sau của Real Python về PEP-8 (ngắn gọn hơn): https://realpython.com/python-pep8/

Bạn không cần thiết phải đọc hết full PEP-8 cũng như trong thực tế bạn cũng không nhất thiết phải tuân thủ quá chặt chẽ PEP-8, tuy nhiên khi làm việc với các dự án và các code base càng phức tạp thì bạn sẽ càng thấy sự quan trọng của những bộ hướng dẫn và quy chuẩn (giúp bạn hiểu code nhanh hơn, debug dễ hơn, việc review code những dễ và nhanh hơn, người khác phối hợp coding với bạn dễ hơn, …) và tốt nhất ta nên tuân theo các hướng dẫn này nhiều nhất có thể.