Không gian tự học dành cho sinh viên UET

Chào mừng đến với UETệ

Tìm bài giảng theo học phần, hiểu lại những kiến thức nền và ôn tập theo nhịp học của bạn.

Bài giảng
Đọc Notes, ôn nhanh với Slides và luyện tập qua các bài có lời giải.
Wiki kiến thức
Tra cứu thuật ngữ và bổ sung kiến thức nền ngay khi cần.
Góc học tập
Ôn bằng flashcard, lưu ghi chú và hẹn giờ tập trung.

Bạn có thể xem lại phần giới thiệu ở cuối trang.

Buy me a coffee

Website hoàn toàn miễn phí. Nếu thấy hữu ích, bạn có thể tặng tôi một cốc cà phê theo thông tin dưới đây nhé!

Mã QR chuyển khoản Momo
Ngân hàng
Momo
Tên người nhận
MOMO - TKTH DO MINH TRI
Số tài khoản
01MMTTT0060405229
Skip to content

Nạp, lưu trữ và truy xuất dữ liệu

Bài 6Cập nhật 18/10/2026Ghi chú của bạn

Xét bài toán nạp một tệp ghi nhận đơn hàng gồm mã giao dịch, thời điểm mua và giá trị thanh toán. Dữ liệu này có thể được lưu trữ dưới dạng tệp văn bản CSV trên máy cục bộ, chuỗi trả về từ cổng dịch vụ web, bảng quan hệ trong cơ sở dữ liệu SQL, hoặc tệp nhị phân nén Parquet. Nếu người xử lý chỉ gọi các hàm đọc mặc định mà không nắm rõ cơ chế phân tích cú pháp, chương trình rất dễ phân loại sai kiểu dữ liệu, nhận nhầm mã ký hiệu hợp lệ thành ô trống, hoặc gây tràn bộ nhớ khi dung lượng tệp vượt quá bộ nhớ RAM khả dụng.

Bài học này làm rõ cơ chế nạp, lưu trữ và truy xuất dữ liệu trong Python: Phân tích cú pháp tệp văn bản và kỹ thuật đọc phân đoạn theo khối, bóc tách cấu trúc bán cấu trúc /XML/HTML, tối ưu hóa lưu trữ cột với Parquet, nhận diện ranh giới an toàn của pickle, giao tiếp với qua HTTP, và truy vấn trực tiếp bằng SQL cùng DuckDB.


1. Đọc và ghi dữ liệu định dạng văn bản phân cách ​

Định dạng văn bản thuần túy (như CSV hoặc tệp phân cách bằng dấu tab) là phương tiện phổ biến nhất để trao đổi dữ liệu giữa các hệ thống nhờ tính tương thích phổ quát và khả năng đọc trực tiếp bằng mắt người.

1.1. Cơ chế phân tích cú pháp của read_csv và read_table ​

Thư viện pandas cung cấp hai hàm nạp tệp văn bản chủ lực:

  • read_csv: Mặc định sử dụng dấu phẩy , làm ký tự phân cách các trường dữ liệu.
  • read_table: Mặc định sử dụng dấu tab \t làm ký tự phân cách.

Về bản chất kỹ thuật, read_csv nhận diện dòng đầu tiên làm tiêu đề cột (header). Khi tệp dữ liệu không có dòng tiêu đề, lập trình viên bắt buộc phải chỉ định header=None và truyền danh sách tên cột vào tham số names. Nếu muốn sử dụng một cột cụ thể làm chỉ mục hàng (Index), ta thiết lập tham số index_col.

python
import io
import pandas as pd

du_lieu_csv = """ma_so,ho_ten,phong_ban,luong
NV01,Nguyễn Văn An,Kỹ thuật,1850
NV02,Trần Thị Bình,Kinh doanh,2100
NV03,Lê Hoàng Cường,Kỹ thuật,1950
"""

# Nạp bảng dữ liệu và đặt cột ma_so làm chỉ mục
df_nhan_vien = pd.read_csv(
    io.StringIO(du_lieu_csv),
    index_col="ma_so"
)
print(df_nhan_vien)

Khi tệp chứa các dòng ghi chú hoặc thông tin phiên bản ở đầu tệp, tham số skiprows cho phép bỏ qua số dòng tương ứng hoặc truyền vào một danh sách các chỉ số dòng cần loại trừ trước khi phân tích cú pháp.

1.2. Kiểm soát giá trị khuyết thiếu và bẫy quy ước quốc tế ​

Mỗi nguồn dữ liệu lại sở hữu một quy ước riêng để đánh dấu ô trống, chẳng hạn "N/A", "NaN", "NULL" hoặc "-999". Hàm read_csv có sẵn một danh sách các chuỗi mặc định được tự động nhận diện là giá trị khuyết thiếu (np.nan).

Tuy nhiên, cơ chế tự động này tiềm ẩn cạm bẫy kỹ thuật nghiêm trọng. Xét bài toán xử lý mã quốc gia theo tiêu chuẩn quốc tế ISO alpha-2: Quốc gia Namibia có mã chính thức là "NA". Nếu nạp dữ liệu một cách mặc định, pandas sẽ tự ý chuyển mã quốc gia "NA" của Namibia thành giá trị khuyết thiếu NaN.

Để xử lý chính xác, ta phải làm chủ hai tham số:

  1. keep_default_na=False: Vô hiệu hóa toàn bộ danh sách quy ước khuyết thiếu mặc định của thư viện.
  2. na_values: Khai báo tường minh tập hợp các chuỗi được coi là khuyết thiếu, áp dụng chung cho toàn bảng hoặc định nghĩa riêng theo từng cột dưới dạng một từ điển (dict).
python
bang_quoc_gia = """ma_qg,ten_qg,chi_so
VN,Việt Nam,85
NA,Namibia,
JP,Nhật Bản,92
"""

# Namibia có mã 'NA' nhưng không phải là dữ liệu thiếu; ô trống ở cột chi_so mới là thiếu
df_qg = pd.read_csv(
    io.StringIO(bang_quoc_gia),
    keep_default_na=False,
    na_values={"chi_so": [""]}
)
print(df_qg)
assert df_qg.loc[df_qg["ma_qg"] == "NA", "ten_qg"].iloc[0] == "Namibia"
assert pd.isna(df_qg.loc[df_qg["ma_qg"] == "NA", "chi_so"].iloc[0])

1.3. Đọc dữ liệu phân đoạn với chunksize và nrows ​

Khi kích thước của tệp văn bản vượt quá dung lượng bộ nhớ RAM khả dụng, việc gọi read_csv trên toàn bộ tệp sẽ lập tức gây ra lỗi tràn bộ nhớ (Out-Of-Memory).

Chiến lược xử lý chuẩn mực gồm hai bước:

  • Khảo sát cấu trúc: Dùng tham số nrows=n để đọc thử n dòng đầu tiên, kiểm tra tên cột, định dạng dữ liệu và dấu phân cách mà không tốn tài nguyên.
  • Xử lý theo luồng phân đoạn (Chunking): Thiết lập tham số chunksize=k. Khi đó, hàm không trả về một tĩnh mà trả về một đối tượng lặp TextFileReader. Mỗi lần lặp qua đối tượng này, pandas sẽ nạp đúng k dòng vào bộ nhớ dưới dạng một DataFrame nhỏ.

Kỹ sư dữ liệu sẽ áp dụng phép tổng hợp trung gian trên từng khối rồi giải phóng bộ nhớ trước khi nạp khối tiếp theo, giúp xử lý các tệp hàng chục gigabyte chỉ với vài trăm megabyte RAM:

python
du_lieu_giao_dich = """id,khu_vuc,doanh_thu
1,Bắc,120
2,Nam,200
3,Bắc,180
4,Trung,150
5,Nam,300
6,Bắc,100
"""

# Đọc từng khối gồm 2 dòng để tính tổng doanh thu và số giao dịch toàn hệ thống
bo_doc = pd.read_csv(io.StringIO(du_lieu_giao_dich), chunksize=2)

tong_doanh_thu = 0
tong_so_luong = 0

for phan_doan in bo_doc:
    tong_doanh_thu += phan_doan["doanh_thu"].sum()
    tong_so_luong += phan_doan["doanh_thu"].count()

gia_tri_trung_binh = tong_doanh_thu / tong_so_luong
print("Tổng doanh thu:", tong_doanh_thu)
print("Số giao dịch:", tong_so_luong)
print("Doanh thu trung bình mỗi đơn:", gia_tri_trung_binh)
assert gia_tri_trung_binh == 175.0

1.4. Ghi dữ liệu ra định dạng văn bản ​

Phương thức to_csv cho phép lưu cấu trúc xuống đĩa hoặc luồng văn bản. Các tham số kỹ thuật cần kiểm soát:

  • sep: Ký tự phân cách giữa các cột (mặc định là dấu phẩy ,).
  • na_rep: Chuỗi ký tự đại diện cho các ô khuyết thiếu khi ghi ra tệp (ví dụ na_rep="NULL").
  • index=False: Bỏ qua cột chỉ mục hàng nếu chỉ mục chỉ là dãy số tự tăng vô nghĩa, tránh tạo thêm cột thừa khi nạp lại.
  • header=False: Loại bỏ dòng tiêu đề cột khi cần nối tiếp dữ liệu vào tệp đã có.

1.5. Xử lý các định dạng phân cách dị biệt với module csv ​

Trong thực tế, nhiều tệp văn bản chứa các trường dữ liệu có chứa cả dấu phân cách bên trong, sử dụng quy ước bao chuỗi phức tạp hoặc dùng nhiều ký tự thoát (escape characters). Thư viện chuẩn csv của Python cho phép định nghĩa các khuôn mẫu phân tích (csv.Dialect) để kiểm soát chính xác hành vi:

python
import csv

dong_du_lieu = '101|"Căn hộ cao cấp, trung tâm"|5500\n102|"Biệt thự ven hồ"|12000'

class QuyUocDacBiet(csv.Dialect):
    delimiter = "|"
    quotechar = '"'
    doublequote = True
    skipinitialspace = True
    lineterminator = "\n"
    quoting = csv.QUOTE_MINIMAL

bo_tach = csv.reader(io.StringIO(dong_du_lieu), dialect=QuyUocDacBiet)
cac_dong = list(bo_tach)
print("Dòng 1 sau khi bóc tách:", cac_dong[0])
assert cac_dong[0][1] == "Căn hộ cao cấp, trung tâm"

2. Dữ liệu bán cấu trúc: JSON, XML và HTML ​

Không phải mọi nguồn dữ liệu đều được sắp xếp ngay ngắn dưới dạng bảng hai chiều. Dữ liệu bán cấu trúc tổ chức thông tin dưới dạng cây hoặc các cặp khóa - giá trị lồng nhau.

2.1. Cấu trúc JSON và chuyển đổi hai chiều ​

(JavaScript Object Notation) là chuẩn giao tiếp thực tế của các ứng dụng web và cổng hiện đại. Python tích hợp sẵn module json với hai cặp hàm chủ đạo:

  • json.loads và json.dumps: Chuyển đổi qua lại giữa chuỗi văn bản và cấu trúc dữ liệu nguyên bản của Python (từ điển, danh sách).
  • json.load và json.dump: Đọc và ghi trực tiếp trên các đối tượng tệp.

Thư viện pandas cung cấp hàm pd.read_json để chuyển chuỗi hoặc tệp thành . Tham số orient xác định cách thức tổ chức cấu trúc dữ liệu trong JSON:

  • orient="records": Mảng gồm các đối tượng từ điển, mỗi đối tượng đại diện cho một hàng trong bảng.
  • orient="columns": Từ điển chứa các cột, mỗi cột là một ánh xạ từ chỉ mục sang giá trị.
  • orient="index": Từ điển chứa các hàng, mỗi khóa là một chỉ mục hàng.
python
chuoi_json = """[
    {"id": 1, "khach_hang": "Công ty A", "doanh_so": 150.5},
    {"id": 2, "khach_hang": "Công ty B", "doanh_so": 280.0}
]"""

df_json = pd.read_json(io.StringIO(chuoi_json), orient="records")
print(df_json)
assert df_json["doanh_so"].sum() == 430.5

2.2. Trích xuất bảng từ trang web với pd.read_html ​

Để thu thập nhanh các bảng dữ liệu được nhúng trên các trang web, hàm pd.read_html sử dụng các công cụ phân tích cú pháp HTML như lxml hay BeautifulSoup để quét toàn bộ văn bản và trích xuất tất cả các khối thẻ <table>...</table>.

Hàm luôn trả về một danh sách các :

python
ma_html = """
<table>
    <thead>
        <tr><th>Kỳ học</th><th>Môn học</th><th>Số tín chỉ</th></tr>
    </thead>
    <tbody>
        <tr><td>Kỳ 1</td><td>Nhập môn lập trình</td><td>4</td></tr>
        <tr><td>Kỳ 2</td><td>Xử lý dữ liệu</td><td>3</td></tr>
    </tbody>
</table>
"""

danh_sach_bang = pd.read_html(io.StringIO(ma_html))
df_mon_hoc = danh_sach_bang[0]
print(df_mon_hoc)
assert df_mon_hoc["Số tín chỉ"].sum() == 7

2.3. Phân tích tài liệu XML bằng lxml.objectify ​

XML (Extensible Markup Language) là định dạng phân cấp lâu đời, thường xuất hiện trong các hệ thống ngân hàng, viễn thông và dữ liệu địa lý. Mô-đun lxml.objectify cung cấp cơ chế phân tích cú pháp hiệu năng cao, ánh xạ các thẻ XML thành các thuộc tính của đối tượng Python một cách tự nhiên:

python
from lxml import objectify

du_lieu_xml = """
<kho_hang>
    <san_pham ma="SP01">
        <ten>Bàn phím cơ</ten>
        <gia>1200</gia>
    </san_pham>
    <san_pham ma="SP02">
        <ten>Chuột quang</ten>
        <gia>450</gia>
    </san_pham>
</kho_hang>
"""

goc = objectify.fromstring(du_lieu_xml.strip().encode("utf-8"))
danh_sach = []
for sp in goc.san_pham:
    danh_sach.append({
        "ma": sp.get("ma"),
        "ten": str(sp.ten),
        "gia": int(sp.gia)
    })

df_kho = pd.DataFrame(danh_sach)
print(df_kho)
assert df_kho["gia"].sum() == 1650

3. Định dạng nhị phân, lưu trữ cột và ranh giới bảo mật ​

Mặc dù định dạng văn bản rất tiện lợi, chúng lại bộc lộ hạn chế lớn: Kích thước tệp cồng kềnh, tốc độ đọc ghi chậm chạp do phải phân tích cú pháp chuỗi, và quan trọng nhất là đánh mất thông tin về kiểu dữ liệu kỹ thuật (Type Degradation). Các định dạng nhị phân ra đời để giải quyết triệt để những điểm yếu này.

3.1. Đọc và ghi tệp Microsoft Excel ​

Trong môi trường phân tích nghiệp vụ, bảng tính Excel (.xlsx, .xls) là phương tiện trao đổi phổ biến. Thư viện pandas tương tác với Excel thông qua công cụ nền openpyxl:

  • pd.read_excel: Đọc trực tiếp một trang tính thành .
  • pd.ExcelFile: Đối tượng đại diện cho một sổ bảng tính gồm nhiều trang (sheets). Việc khởi tạo ExcelFile một lần giúp tăng tốc đáng kể khi cần đọc lần lượt nhiều trang tính từ cùng một tệp đĩa:
python
bo_dem = io.BytesIO()
with pd.ExcelWriter(bo_dem, engine="openpyxl") as ghi_excel:
    df_nhan_vien.to_excel(ghi_excel, sheet_name="NhanVien")

bo_dem.seek(0)
so_bang_tinh = pd.ExcelFile(bo_dem)
print("Danh sách trang tính:", so_bang_tinh.sheet_names)
df_doc_lai = so_bang_tinh.parse("NhanVien", index_col="ma_so")
assert len(df_doc_lai) == 3

3.2. Định dạng khoa học HDF5 ​

HDF5 (Hierarchical Data Format 5) là định dạng lưu trữ nhị phân chuyên dụng trong tính toán khoa học, cho phép lưu trữ hàng tỷ phần tử số học kèm theo cơ chế nén trên đĩa. Điểm nổi trội của HDF5 là tổ chức dữ liệu như một hệ thống tệp thu nhỏ bên trong một tệp duy nhất, chia thành các nhóm (groups) và tập dữ liệu (datasets).

Pandas hỗ trợ HDF5 qua lớp pd.HDFStore:

  • Chế độ fixed: Lưu trữ mảng khối nhị phân thuần túy, tốc độ ghi và đọc cực nhanh nhưng không thể truy vấn bộ phận.
  • Chế độ table: Lưu trữ theo định dạng bảng quan hệ của PyTables. Muốn thực hiện truy vấn bộ phận bằng mệnh đề where="luong > 1900" mà không cần nạp toàn bộ tập dữ liệu lên RAM, ta bắt buộc phải khai báo tham số data_columns chứa danh sách các cột dùng làm điều kiện lọc (hoặc data_columns=True). Nếu không khai báo, PyTables sẽ gom các cột vào một khối nhị phân chung và không thể đánh chỉ mục để lọc trực tiếp trên đĩa:
python
import tempfile
import os

with tempfile.NamedTemporaryFile(suffix=".h5", delete=False) as tep_tam:
    duong_dan_h5 = tep_tam.name

try:
    with pd.HDFStore(duong_dan_h5, mode="w") as kho:
        kho.put("giao_dich", df_nhan_vien, format="table", data_columns=["luong"])
        # Truy vấn trực tiếp các dòng có mức lương lớn hơn 1900
        ket_qua_loc = kho.select("giao_dich", where="luong > 1900")
        assert len(ket_qua_loc) == 2
except (ImportError, Exception):
    # PyTables có thể không được cài đặt sẵn trong môi trường thử nghiệm cục bộ
    pass
finally:
    if os.path.exists(duong_dan_h5):
        os.remove(duong_dan_h5)

3.3. Định dạng định hướng cột Apache Parquet ​

Trong kỹ nghệ dữ liệu hiện đại, Apache Parquet là chuẩn mực lưu trữ dữ liệu phân tích. Khác với CSV tổ chức theo từng dòng liên tiếp, Parquet tổ chức dữ liệu theo từng cột (Columnar Storage):

  1. Hiệu năng I/O vượt trội nhờ đọc cột chọn lọc: Khi người dùng chỉ cần phân tích cột luong, trình đọc Parquet chỉ đọc đúng các dải byte của cột đó trong từng nhóm hàng (Row Groups), bỏ qua các khối dữ liệu của những cột không dùng đến. Điều này giúp giảm đáng kể lượng byte phải đọc từ đĩa so với việc phải duyệt qua toàn bộ các dòng và cột của tệp CSV dạng hàng.
  2. Khả năng nén tối ưu: Vì một cột chỉ chứa cùng một kiểu dữ liệu đồng nhất, các thuật toán nén hiện đại (Snappy, Zstd, Dictionary Encoding) có thể nén dữ liệu chặt chẽ hơn nhiều so với việc nén văn bản dòng không đồng nhất.
  3. Lưu trữ kiểu dữ liệu kỹ thuật (Schema Preservation): Định dạng Parquet lưu trữ thông tin kiểu dữ liệu chi tiết trong phần siêu dữ liệu ở đuôi tệp (Metadata Footer), bao gồm số nguyên, số thực, thời gian có hoặc không gắn múi giờ, kiểu boolean và cấu trúc lồng nhau. Điều này giúp hạn chế tình trạng suy giảm kiểu vốn thường gặp khi đọc lại CSV, mặc dù các kiểu đối tượng Python tùy biến hoặc kiểu mở rộng đặc thù của pandas vẫn cần khai báo lược đồ chuyển đổi tương ứng khi ghi đọc.

3.4. Ranh giới an toàn của Pickle ​

Module pickle là công cụ tuần tự hóa nhị phân mặc định của Python. Phương thức df.to_pickle() và hàm pd.read_pickle() cho phép đóng gói mọi đối tượng Python xuống đĩa và tái tạo lại chính xác.

Tuy nhiên, pickle tiềm ẩn một nguy cơ an ninh nghiêm trọng: Lỗ hổng thực thi mã tùy ý (Arbitrary Code Execution).

Về mặt cơ chế: Khi một đối tượng được tuần tự hóa (pickling), Python có thể gọi phương thức __reduce__ của đối tượng để sinh ra một hàm khởi tạo (callable) và danh sách đối số. Khi giải tuần tự hóa (unpickling), máy ảo của pickle sẽ tự động triệu gọi trực tiếp bất kỳ callable nào được ghi nhận trong luồng byte đó mà không cần gọi lại phương thức __reduce__. Kẻ tấn công có thể cấu tạo một luồng byte pickle chứa lời gọi tới các hàm thực thi lệnh hệ thống nguy hiểm (như hàm chạy tiến trình hệ điều hành). Khi người dùng gọi pd.read_pickle(), mã độc sẽ được kích hoạt ngay lập tức dưới quyền của tiến trình Python đang chạy.

Yêu cầu nguồn dữ liệu đáng tin cậy

Chỉ sử dụng pickle để lưu tạm các đối tượng Python nội bộ giữa các bước xử lý trong một môi trường hoàn toàn kiểm soát được. Tuyệt đối không nạp tệp pickle nhận từ mạng Internet, tải về từ bên ngoài hoặc từ các nguồn chưa được xác thực độ tin cậy. Khi trao đổi dữ liệu giữa các hệ thống, hãy ưu tiên các định dạng dữ liệu có cấu trúc không mang mã thực thi nhúng như Parquet, Arrow, hoặc CSV với bộ phân tích an toàn, đồng thời kiểm tra kích thước và giới hạn tài nguyên của tệp đầu vào.


4. Giao tiếp với Web API và cơ sở dữ liệu ​

Dữ liệu thực tế thường xuyên được truy xuất từ các giao diện lập trình ứng dụng qua web () hoặc các hệ quản trị cơ sở dữ liệu doanh nghiệp.

4.1. Tiêu thụ Web API và nguyên tắc lưu trữ phản hồi thô ​

Các dịch vụ web cung cấp dữ liệu qua giao thức HTTP/HTTPS. Thư viện requests là công cụ chuẩn mực trong Python để gửi các yêu cầu mạng. Khi xây dựng hàm thu thập dữ liệu từ , một thiết kế tốt cần bảo đảm:

  • Cho phép truyền vào session (hỗ trợ tái sử dụng kết nối TCP hoặc truyền session giả lập trong kiểm thử tự động).
  • Thiết lập timeout rõ ràng để tránh treo tiến trình khi mạng gặp sự cố.
  • Gọi response.raise_for_status() để phát hiện và ném lỗi tức thì khi máy chủ trả về mã lỗi HTTP (4xx,5xx).
  • Lưu trực tiếp response.content (chuỗi byte nhị phân thô) xuống tệp trước khi xử lý: Việc chuyển đổi thành từ điển rồi dùng json.dumps() ghi lại không bảo toàn được luồng byte HTTP nguyên gốc (có thể làm thay đổi thứ tự khóa, khoảng trắng, định dạng số thực hoặc làm mất tính toàn vẹn của mã băm kiểm tra checksum).
python
import io
import json
import os
import tempfile
import pandas as pd
import requests

def lay_va_luu_du_lieu_api(url, duong_dan_luu, session=None, timeout=10):
    """
    Gửi yêu cầu HTTP GET, kiểm tra lỗi, lưu luồng byte thô xuống đĩa
    và trích xuất dữ liệu JSON thành DataFrame.
    """
    s = session or requests.Session()
    phan_hoi = s.get(url, timeout=timeout)
    phan_hoi.raise_for_status()

    # Lưu chính xác luồng byte nhị phân nguyên bản từ HTTP, không tái tuần tự hóa dict
    with open(duong_dan_luu, "wb") as f:
        f.write(phan_hoi.content)

    du_lieu = phan_hoi.json()
    df = pd.DataFrame(du_lieu["quan_sat"])
    df["tram_do"] = du_lieu["tram_do"]
    return df

# Lớp giả lập phản hồi HTTP phục vụ kiểm thử offline không phụ thuộc mạng ngoài
class FakeResponse:
    def __init__(self, content_bytes, status_code=200):
        self.content = content_bytes
        self.status_code = status_code

    def raise_for_status(self):
        if self.status_code >= 400:
            raise requests.HTTPError(f"Lỗi HTTP {self.status_code}")

    def json(self):
        return json.loads(self.content.decode("utf-8"))

class FakeSession:
    def __init__(self, fake_response):
        self._fake_response = fake_response

    def get(self, url, timeout=10):
        return self._fake_response

du_lieu_http_goc = (
    b'{"tram_do": "HN_01", "quan_sat": ['
    b'{"gio": "08:00", "nhiet_do": 26.5, "do_am": 78}, '
    b'{"gio": "12:00", "nhiet_do": 32.0, "do_am": 65}, '
    b'{"gio": "16:00", "nhiet_do": 29.5, "do_am": 72}'
    b']}'
)

fake_session = FakeSession(FakeResponse(du_lieu_http_goc, status_code=200))

with tempfile.TemporaryDirectory() as thu_muc_tam:
    tep_tho = os.path.join(thu_muc_tam, "thoi_tiet_raw.json")
    df_thoi_tiet = lay_va_luu_du_lieu_api(
        "https://api.example.com/weather/hn01",
        tep_tho,
        session=fake_session,
        timeout=5
    )
    # Kiểm tra luồng byte lưu trên đĩa trùng khớp 100% dữ liệu gốc
    with open(tep_tho, "rb") as f:
        noi_dung_luu = f.read()
    assert noi_dung_luu == du_lieu_http_goc
    assert df_thoi_tiet["nhiet_do"].max() == 32.0

print("DataFrame trích xuất từ dữ liệu API:")
print(df_thoi_tiet)

Hai lợi ích thực tiễn của nguyên tắc Lưu trữ Phản hồi Thô (Raw Response Persistence):

  1. Tính tái lập (Reproducibility): Khi logic nghiệp vụ thay đổi hoặc cần bóc tách thêm trường dữ liệu mới (chẳng hạn tọa độ trạm đo), ta có thể chạy lại mã nguồn từ tệp thô đã lưu mà không phụ thuộc vào trạng thái của máy chủ bên ngoài.
  2. Bảo toàn hạn ngạch (Rate Limit): Tránh nguy cơ bị khóa tài khoản hoặc gián đoạn thử nghiệm do chạm ngưỡng giới hạn số lần gọi trong ngày của nhà cung cấp.

4.2. Tương tác với cơ sở dữ liệu quan hệ (SQL) ​

Thư viện pandas kết hợp với sqlite3 hoặc SQLAlchemy cho phép tương tác trực tiếp với các hệ quản trị cơ sở dữ liệu quan hệ (PostgreSQL, MySQL, SQLite):

  • to_sql: Xuất cấu trúc thành bảng trong cơ sở dữ liệu (if_exists="replace", "append").
  • read_sql hoặc read_sql_query: Nạp dữ liệu từ câu truy vấn SQL trực tiếp thành .

Nguyên tắc tối ưu hóa hàng đầu của kỹ sư dữ liệu là: Lọc tại nguồn (Filter at Source). Luôn đẩy các phép lọc hàng (WHERE), phép chọn cột và tổng hợp nhóm (GROUP BY) xuống động cơ cơ sở dữ liệu thực thi, chỉ kéo về máy khách phần kết quả tinh gọn nhất:

python
import sqlite3

ket_noi = sqlite3.connect(":memory:")

# Ghi dữ liệu mẫu vào cơ sở dữ liệu SQLite trong bộ nhớ
df_nhan_vien.to_sql("nhan_vien", ket_noi, if_exists="replace", index=True)

# Thực thi truy vấn lọc trực tiếp tại cơ sở dữ liệu
cau_lenh_sql = """
SELECT ma_so, ho_ten, luong
FROM nhan_vien
WHERE luong >= 1950
ORDER BY luong DESC
"""

df_loc_sql = pd.read_sql_query(cau_lenh_sql, ket_noi, index_col="ma_so")
print("Kết quả lọc từ SQL:")
print(df_loc_sql)
assert len(df_loc_sql) == 2
ket_noi.close()

4.3. Truy vấn dữ liệu phân tích tại chỗ với DuckDB ​

Đối với bài toán phân tích các tệp dữ liệu cục bộ lớn, việc cài đặt các hệ quản trị cơ sở dữ liệu truyền thống gây tốn kém thời gian cấu hình và quản trị. Dự án DuckDB ra đời như một hệ cơ sở dữ liệu phân tích nhúng chạy trực tiếp bên trong tiến trình của ứng dụng:

  • Định hướng cột (Columnar Engine): Tối ưu hóa cho các phép toán tổng hợp phân tích (OLAP).
  • Truy vấn trực tiếp không cần bước nạp trước: DuckDB có khả năng quét và thực thi các câu truy vấn SQL trực tiếp trên các tệp CSV (thông qua trình phân tích cú pháp theo khối) hoặc đọc trực tiếp các khối cột Parquet trên đĩa cứng mà không cần nạp toàn bộ vào bảng cơ sở dữ liệu nội bộ trước. Cần lưu ý rằng việc quét CSV trực tiếp đòi hỏi giải mã chuỗi văn bản thành các kiểu dữ liệu trong bộ nhớ chứ không phải là thao tác sao chép không bộ nhớ (zero-copy).
  • Tích hợp sâu với pandas: Kết quả truy vấn SQL của DuckDB có thể chuyển đổi thành thông qua phương thức .df().

6. Tổng kết ​

  1. Kiểm soát ký tự khuyết thiếu: Luôn thận trọng với các quy ước mặc định của read_csv. Thiết lập keep_default_na=False và chỉ định na_values chính xác để không làm sai lệch các mã định danh hợp lệ như "NA".
  2. Khai thác kỹ thuật đọc phân đoạn: Sử dụng chunksize để xử lý các tệp dữ liệu vượt quá kích thước RAM khả dụng, tích lũy thống kê qua từng khối để bảo vệ tài nguyên hệ thống.
  3. Khai thác định dạng cột Parquet: Thay thế các tệp CSV trung gian bằng Apache Parquet để tiết kiệm dung lượng lưu trữ, tăng tốc độ truy xuất cột và lưu giữ kiểu dữ liệu kỹ thuật.
  4. Nhận thức ranh giới an toàn của Pickle: Chỉ sử dụng pickle trong môi trường nội bộ đáng tin cậy. Tuyệt đối không nạp tệp pickle từ nguồn lạ nhằm loại trừ nguy cơ máy ảo pickle tự động kích hoạt các hàm thực thi mã độc tùy ý.
  5. Kỷ luật lưu trữ phản hồi thô: Luôn lưu trực tiếp chuỗi byte HTTP thô (response.content) nhận từ xuống đĩa trước khi trích xuất bảng biểu nhằm bảo đảm tính tái lập và bảo vệ dữ liệu nguyên gốc.
  6. Đẩy tác vụ xuống tầng cơ sở dữ liệu: Sử dụng các câu truy vấn SQL hoặc tận dụng sức mạnh tính toán tại chỗ của DuckDB để lọc và tổng hợp dữ liệu ngay tại nguồn trước khi kéo về bộ nhớ.

Tài liệu tham khảo ​

5. Bài tập rèn luyện ​

Bài tập 1: Đọc bảng giá có mã khu đặc biệt

Một tệp dữ liệu giả lập gia_theo_khu.csv lưu trữ thông tin giá phòng như sau:

text
khu,gia
NA,100
TB,80
NA,
TB,0

Trong đó: Ký hiệu NA là mã khu vực hợp lệ (không phải giá trị thiếu), ô trống là trường hợp chưa ghi nhận được giá, và giá trị 0 là mức giá đã được ghi nhận thực tế. Hãy viết mã nguồn nạp tệp dữ liệu này và tính giá trung bình theo từng khu vực sao cho kết quả hoàn toàn chuẩn xác.

Lời giải

Trọng tâm kỹ thuật của bài toán là vô hiệu hóa danh sách giá trị thiếu mặc định để bảo toàn mã khu NA, đồng thời chỉ định ô trống ở cột gia là giá trị khuyết thiếu:

python
csv_gia = """khu,gia
NA,100
TB,80
NA,
TB,0
"""

df_gia = pd.read_csv(
    io.StringIO(csv_gia),
    keep_default_na=False,
    na_values={"gia": [""]}
)

# Tính giá trung bình theo từng khu vực
ket_qua = df_gia.groupby("khu")["gia"].mean()
print(ket_qua)

# Kiểm tra tính đúng đắn của phép tính
assert ket_qua["NA"] == 100.0
assert ket_qua["TB"] == 40.0

Khu NA có hai quan sát gồm giá trị 100 và ô trống. Vì ô trống bị bỏ qua nên giá trung bình của khu này là 100. Khu TB có hai quan sát là 80 và 0, do đó giá trung bình là (80+0)/2=40,0.

Bài tập 2: Lọc ngày nóng từ dữ liệu API thời tiết

Dữ liệu dự báo thời tiết trả về từ cổng dịch vụ có cấu trúc như sau:

python
phan_hoi_api = {
    "timezone": "America/Phoenix",
    "daily_units": {"temperature_2m_max": "°F"},
    "daily": {
        "time": ["2026-10-01", "2026-10-02", "2026-10-03"],
        "temperature_2m_max": [95, 100, 88]
    }
}

Nhiệt độ tối đa hàng ngày đang được tính theo thang độ Fahrenheit (°F). Công thức chuyển đổi sang thang độ Celsius (°C) là:

T(∘C)=T(∘F)−321,8

Hãy tạo một từ dữ liệu trên, bổ sung cột nhiệt độ tính theo độ C, và lọc ra danh sách các ngày có nhiệt độ tối đa từ 35∘C trở lên.

Lời giải

Ta trích xuất từ điển con daily để tạo , sau đó áp dụng phép toán vector hóa trên cột để đổi đơn vị:

python
import numpy as np

phan_hoi_api = {
    "timezone": "America/Phoenix",
    "daily_units": {"temperature_2m_max": "°F"},
    "daily": {
        "time": ["2026-10-01", "2026-10-02", "2026-10-03"],
        "temperature_2m_max": [95, 100, 88]
    }
}

df_thoi_tiet = pd.DataFrame(phan_hoi_api["daily"])
df_thoi_tiet["nhiet_do_c"] = (df_thoi_tiet["temperature_2m_max"] - 32) / 1.8

ngay_nong = df_thoi_tiet[df_thoi_tiet["nhiet_do_c"] >= 35]
print("Các ngày có nhiệt độ tối đa từ 35 độ C trở lên:")
print(ngay_nong[["time", "nhiet_do_c"]])

assert ngay_nong["time"].tolist() == ["2026-10-01", "2026-10-02"]
np.testing.assert_allclose(df_thoi_tiet["nhiet_do_c"], [35.0, 37.777778, 31.111111], rtol=1e-5)

Nhiệt độ sau khi chuyển đổi lần lượt là 35,0∘C, 37,8∘C và 31,1∘C. Hai ngày thỏa mãn điều kiện là 2026-10-01 và 2026-10-02.

Bài tập 3: Đếm số chỗ ở thiếu giá theo từng khu bằng SQL

Cho bảng dữ liệu cho_o được lưu trữ trong cơ sở dữ liệu với các bản ghi như sau:

idkhugia
001A100
002B80
003A200
004B120
005ANULL

Hãy viết một câu truy vấn SQL đếm số chỗ ở chưa có thông tin giá của từng khu vực. Yêu cầu: Kết quả phải hiển thị đầy đủ mọi khu vực, kể cả khu vực không có chỗ ở nào bị thiếu giá.

Lời giải

Nếu sử dụng mệnh đề WHERE gia IS NULL kết hợp với GROUP BY khu, khu B sẽ bị loại bỏ hoàn toàn khỏi bảng kết quả vì không có dòng nào thỏa mãn điều kiện lọc.

Giải pháp chuẩn mực là lấy tổng số bản ghi trừ đi số bản ghi có giá trị ở cột gia (hàm COUNT(gia) tự động bỏ qua các giá trị NULL):

python
ket_noi = sqlite3.connect(":memory:")

df_bai_tap = pd.DataFrame({
    "id": ["001", "002", "003", "004", "005"],
    "khu": ["A", "B", "A", "B", "A"],
    "gia": [100.0, 80.0, 200.0, 120.0, None]
})
df_bai_tap.to_sql("cho_o", ket_noi, if_exists="replace", index=False)

cau_truy_van = """
SELECT khu, COUNT(*) - COUNT(gia) AS so_thieu
FROM cho_o
GROUP BY khu
ORDER BY khu ASC
"""

df_dem_thieu = pd.read_sql_query(cau_truy_van, ket_noi)
print(df_dem_thieu)

assert df_dem_thieu.to_dict("records") == [
    {"khu": "A", "so_thieu": 1},
    {"khu": "B", "so_thieu": 0}
]
ket_noi.close()

Khu A có 3 bản ghi nhưng chỉ có 2 bản ghi có giá, số lượng thiếu là 3−2=1. Khu B có 2 bản ghi và cả 2 đều có giá, số lượng thiếu là 2−2=0.


Lab 06: Dữ liệu ngoài, đọc tệp chọn lọc, lưu trữ Parquet và truy vấn DuckDB ​

Một tệp có 90 cột nhưng bài toán chỉ cần năm cột. Ta sẽ đọc đúng phần cần dùng, lưu và đọc lại kết quả, ghép dữ liệu thời tiết theo ngày rồi kiểm tra bằng SQL. Các ví dụ nhỏ chạy độc lập để quan sát kiểu dữ liệu và kết quả từng bước.

Bối cảnh và snapshot dữ liệu tham chiếu ​

Có thể thử cùng quy trình trên snapshot Inside Airbnb của Santiago ngày 29/06/2026. Cần phân biệt bảng rút gọn với bảng đầy đủ khi chọn tệp và cột:

  • Địa chỉ kho dữ liệu gốc: https://data.insideairbnb.com/chile/rm/santiago/
  • Phiên bản snapshot tham chiếu: Bản lưu trữ ngày 29 tháng 06 năm 2026 (2026-06-29/visualisations/listings.csv, visualisations/reviews.csv, và bảng đầy đủ 90 cột data/listings.csv.gz).
  • Nguồn dữ liệu thời tiết lịch sử: Dịch vụ Open-Meteo Historical Weather tại địa chỉ https://archive-api.open-meteo.com/v1/archive.

NOTE

Quy ước dữ liệu mẫu trong Lab 06: Toàn bộ các bảng số liệu mẫu được khởi tạo trong tài liệu này là dữ liệu giả lập quy mô nhỏ. Mục đích của dữ liệu nhỏ là giúp người học đối chiếu từng bước tính toán, theo dõi sự thay đổi kiểu dữ liệu và kiểm tra độ chính xác của các câu lệnh SQL một cách độc lập mà không phụ thuộc vào kết nối mạng ngoài. Tuyệt đối không suy diễn các con số mẫu nhỏ này thành kết luận đại diện cho toàn bộ thị trường lưu trú thực tế tại Santiago.

Khởi tạo dữ liệu dùng chung ​

Mọi bài tập sử dụng các cấu trúc dữ liệu mẫu giả lập đại diện cho tệp đánh giá, tệp chỗ ở đầy đủ và phản hồi thời tiết lịch sử:

python
import json
import os
import tempfile
from pathlib import Path
import numpy as np
import pandas as pd
import duckdb

# Kích hoạt hành vi Copy-on-Write của pandas
if pd.__version__.startswith("2."):
    pd.set_option("mode.copy_on_write", True)

# 1. Dữ liệu đánh giá giả lập: 51 dòng phân bổ qua các năm
_danh_sach_ngay = []
for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong
_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

# 2. Dữ liệu chỗ ở đầy đủ giả lập: 6 chỗ ở với thông tin giá và ngày đánh giá đầu tiên
DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

# 3. Phản hồi thời tiết lịch sử mẫu: 10 ngày đầu tháng 01 năm 2025 tại Santiago
DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

# Ghi sẵn các tệp vào thư mục hiện hành để phục vụ thực thi độc lập
DEMO_REVIEWS.to_csv("reviews.csv", index=False)
DEMO_LISTINGS_FULL[["id", "name", "room_type"]].to_csv("listings.csv", index=False)
DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)
Path("weather.json").write_text(
    json.dumps(DEMO_WEATHER, ensure_ascii=False, indent=2), encoding="utf-8"
)

Bài 1: Đọc tệp bảng lớn có chọn lọc với tham số cột và ngày giờ ​

Mục tiêu: Nắm vững phương pháp tối ưu hóa bộ nhớ khi nạp các tệp dữ liệu lớn bằng cách chỉ chọn đọc các cột cần thiết và chuyển đổi kiểu ngày giờ ngay trong quá trình nạp.

Dữ liệu đầu vào:

  • Tệp dữ liệu chỗ ở đầy đủ listings_full.csv chứa nhiều cột thông tin.
  • Danh sách các cột cần lấy: ["id", "room_type", "price", "first_review", "number_of_reviews"].
  • Danh sách các cột định dạng thời gian cần chuyển đổi sang datetime64: ["first_review"].

Thao tác yêu cầu:

  1. Triển khai hàm read_selected(path, columns: list, date_columns: list) -> pd.DataFrame sử dụng pd.read_csv với hai đối số trọng tâm: usecols để chỉ định danh sách cột và parse_dates để ép kiểu thời gian.
  2. Đảm bảo các dòng có ô ngày trống được chuyển thành giá trị NaT thay vì chuỗi rỗng hoặc lỗi cú pháp.
  3. Đo lường sự khác biệt về dung lượng bộ nhớ sử dụng giữa phương pháp đọc toàn bộ bảng và phương pháp chỉ đọc các cột được chọn.

Trường hợp biên cần chú ý:

  • Không nạp toàn bộ bảng vào bộ nhớ rồi mới dùng cú pháp chọn lọc cột trên đối tượng .
  • Danh sách date_columns có thể rỗng hoặc là tập con của columns.
Lời giải

Cách 1: Đọc đúng cột cần dùng bằng read_csv ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

# Bắt đầu lời giải


def read_selected(path, columns: list, date_columns: list) -> pd.DataFrame:
    # Đọc tệp có chọn lọc cột và chuyển đổi ngày giờ
    df = pd.read_csv(
        path, usecols=columns, parse_dates=date_columns if date_columns else False
    )
    return df


# Thực thi mẫu với tệp listings_full.csv
cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]
cot_ngay = ["first_review"]
bang_chon_loc = read_selected("listings_full.csv", cot_can_doc, cot_ngay)
print("Kích thước bảng:", bang_chon_loc.shape)
print("Kiểu dữ liệu first_review:", bang_chon_loc["first_review"].dtype)

# Kết thúc lời giải
Giải thích Cách 1 ​

usecols=columns yêu cầu trình đọc chỉ dựng các cột cần dùng trong . Đọc CSV vẫn phải quét nội dung tệp, nên không thể suy ra chọn năm trong 90 cột sẽ chỉ đọc năm phần chín mươi số byte. Lợi ích trực tiếp là giảm số cột được lưu và xử lý trong bộ nhớ.

parse_dates=date_columns đề nghị pandas phân tích các cột ngày. Dữ liệu ngày ISO hợp lệ trong ví dụ được chuyển sang kiểu thời gian, còn ô ngày trống thành NaT. Khi danh sách ngày rỗng, mã truyền False để không yêu cầu chuyển đổi ngày. Với chuỗi ngày lỗi ngoài miền của bài, cần kiểm tra kiểu sau khi đọc thay vì mặc định cho rằng mọi chuỗi đã được chuyển thành công.

.shape trả cặp số dòng và số cột, nên (6, 5) xác nhận giữ đủ sáu chỗ ở nhưng chỉ lấy năm cột. Kiểu thời gian có thể là datetime64[us] hoặc datetime64[ns] tùy phiên bản và độ phân giải. Trong môi trường đã chạy kiểm tra, cột first_review là datetime64[us], không phải số thực.

Output Cách 1 ​
idroom_typepricefirst_reviewnumber_of_reviews
11Entire home/apt$45,647.002023-03-1514
12Private room$18,500.002023-08-029
13Entire home/apt$1,120,000.002024-02-118
14Entire home/aptNaN2023-11-204
15Entire home/apt$59,000.002025-01-026
16Private room$25,990.50NaT0

Bản in có Kích thước bảng: (6, 5) và Kiểu dữ liệu first_review: datetime64[us] trong môi trường kiểm tra.

Cách 2: Khai báo thêm kiểu dữ liệu khi đọc ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]

cot_ngay = ["first_review"]

# Bắt đầu lời giải


def read_selected_v2(
    path, columns: list, date_columns: list, dtypes: dict = None
) -> pd.DataFrame:
    tep_nguon = Path(path)
    if not tep_nguon.exists():
        raise FileNotFoundError(f"Không tìm thấy tệp dữ liệu tại: {path}")

    # Xác định các cột ngày hợp lệ nằm trong danh sách cột yêu cầu
    cac_cot_ngay_hop_le = (
        [c for c in date_columns if c in columns] if date_columns else False
    )

    # Đọc dữ liệu với cấu hình kiểu tường minh
    ket_qua = pd.read_csv(
        tep_nguon, usecols=columns, parse_dates=cac_cot_ngay_hop_le, dtype=dtypes
    )
    return ket_qua


# Thực thi mẫu với việc cố định kiểu chuỗi cho mã định danh
bang_chon_loc_v2 = read_selected_v2(
    "listings_full.csv", cot_can_doc, cot_ngay, dtypes={"id": "string"}
)
print("Kiểu cột id sau khi ép chuỗi:", bang_chon_loc_v2["id"].dtype)

# Kết thúc lời giải
Giải thích Cách 2 ​

Path(path) biểu diễn đường dẫn thành đối tượng có các thao tác tệp. .exists() kiểm tra sự tồn tại trước khi đọc. Việc kiểm tra này giúp báo lỗi rõ vị trí tệp, còn pandas vẫn có thể phát sinh lỗi nếu tệp đổi trạng thái sau đó hoặc không thể đọc được.

dtypes={"id": "string"} đưa mã định danh về kiểu chuỗi ngay lúc đọc, hữu ích khi mã có số 0 ở đầu. Bảng vẫn hiển thị 11, 12 nhưng các giá trị này là chuỗi, không còn dùng như số để cộng. Hàm lọc date_columns theo columns để chỉ phân tích những cột ngày có được chọn.

dtype không làm dữ liệu nhỏ hơn trong mọi trường hợp. Chọn kiểu dựa vào ý nghĩa cột và phạm vi giá trị, sau đó dùng .memory_usage(deep=True) để đo khi cần. Tham số deep=True giúp tính thêm bộ nhớ nội dung của dữ liệu chuỗi được lưu dưới dạng đối tượng, thay vì chỉ đếm vùng chứa tham chiếu.

Output Cách 2 ​
idroom_typepricefirst_reviewnumber_of_reviews
11Entire home/apt$45,647.002023-03-1514
12Private room$18,500.002023-08-029
13Entire home/apt$1,120,000.002024-02-118
14Entire home/aptNaN2023-11-204
15Entire home/apt$59,000.002025-01-026
16Private room$25,990.50NaT0

Dòng kiểm tra riêng của Cách 2 là Kiểu cột id sau khi ép chuỗi: string. Cột id trong bảng trên được giữ ở kiểu chuỗi.

So sánh hai cách ​

Cách 1 đủ khi kiểu tự suy diễn phù hợp. Cách 2 bổ sung kiểm tra đường dẫn và cấu hình kiểu, thích hợp với quy trình cần ổn định. Muốn so sánh giá trị hai bảng phải nhớ cột id đã có chủ ý khác kiểu. Dung lượng bộ nhớ cần được đo trên cùng dữ liệu và cùng cấu hình, không suy ra từ số cột đơn thuần.


Bài 2: Làm sạch cột giá tiền dạng chuỗi và chuẩn hóa kiểu số thực ​

Mục tiêu: Xử lý chuỗi văn bản biểu diễn tiền tệ chứa ký hiệu đơn vị và dấu phân cách hàng nghìn, chuyển đổi thành số thực có thể tính toán toán học.

Dữ liệu đầu vào: Cột giá price dưới dạng một Series chuỗi: ["$45,647.00", "$18,500.00", "$1,120,000.00", None, "$59,000.00", "$25,990.50"].

Thao tác yêu cầu:

  1. Triển khai hàm clean_price(prices: pd.Series) -> pd.Series để loại bỏ ký hiệu $ và dấu phẩy ,, sau đó chuyển đổi thành kiểu float64.
  2. Bảo toàn các giá trị khuyết thiếu ban đầu dưới dạng NaN trong kết quả đầu ra.
  3. Không làm thay đổi đối tượng Series ban đầu được truyền vào hàm.

Trường hợp biên cần chú ý:

  • Nếu sử dụng str.replace("$", "", regex=True), trong cú pháp biểu thức chính quy ký tự $ có ý nghĩa là vị trí kết thúc chuỗi. Khi đó ký hiệu tiền tệ không bị xóa mà vị trí cuối chuỗi bị thay thế. Bắt buộc phải đặt regex=False khi thay thế ký tự thuần túy.
Lời giải

Cách 1: Xóa hai ký tự đã biết rồi ép sang float ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

# Bắt đầu lời giải


def clean_price(prices: pd.Series) -> pd.Series:
    # Bước 1: Loại bỏ ký hiệu đô la với regex=False
    s1 = prices.str.replace("$", "", regex=False)
    # Bước 2: Loại bỏ dấu phẩy phân tách hàng nghìn
    s2 = s1.str.replace(",", "", regex=False)
    # Bước 3: Chuyển đổi sang kiểu số thực float
    ket_qua = s2.astype(float)
    return ket_qua


# Thực thi mẫu trên cột giá của bảng chỗ ở
gia_sach = clean_price(DEMO_LISTINGS_FULL["price"])
print("Giá sau làm sạch:")
print(gia_sach.tolist())
print("Trung vị giá:", gia_sach.median())

# Kết thúc lời giải
Giải thích Cách 1 ​

Hai lần .str.replace(..., regex=False) lần lượt xóa dấu $ và dấu phẩy khỏi từng chuỗi. Cờ regex=False yêu cầu so khớp nguyên văn. Nếu dùng regex cho $ mà không thoát ký tự, nó biểu diễn vị trí kết thúc chuỗi và không còn nghĩa ký hiệu tiền.

Sau bước này, chuỗi $45,647.00 thành 45647.00. .astype(float) phân tích chuỗi số thành số thực, giữ ô khuyết dưới dạng NaN. Nếu còn văn bản như liên hệ, cách này phát sinh lỗi thay vì tự đoán một giá trị. Đó là lựa chọn phù hợp khi định dạng đầu vào đã được quy định chặt.

.median() bỏ ô khuyết và lấy trung vị năm giá đã đọc được. Danh sách tăng dần là 18500, 25990.5, 45647, 59000, 1120000, nên giá ở giữa bằng 45647.0. Đây là trung vị mẫu sáu dòng, không phải giá đại diện cho cả thành phố.

Output Cách 1 ​
Vị tríChuỗi ban đầuGiá số
0$45,647.0045647.0
1$18,500.0018500.0
2$1,120,000.001120000.0
3NoneNaN
4$59,000.0059000.0
5$25,990.5025990.5

Dòng in trung vị trả 45647.0.

Cách 2: Gom ký tự cần xóa bằng regex và dùng to_numeric ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import numpy as np

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)


def clean_price(prices: pd.Series) -> pd.Series:
    # Bước 1: Loại bỏ ký hiệu đô la với regex=False
    s1 = prices.str.replace("$", "", regex=False)
    # Bước 2: Loại bỏ dấu phẩy phân tách hàng nghìn
    s2 = s1.str.replace(",", "", regex=False)
    # Bước 3: Chuyển đổi sang kiểu số thực float
    ket_qua = s2.astype(float)
    return ket_qua


gia_sach = clean_price(DEMO_LISTINGS_FULL["price"])

# Bắt đầu lời giải


def clean_price_v2(prices: pd.Series) -> pd.Series:
    # Loại bỏ đồng thời cả $ và dấu phẩy bằng một biểu thức chính quy gọn gàng
    chuoi_sach = prices.str.replace(r"[$,]", "", regex=True)
    # Chuyển đổi an toàn sang kiểu số thực
    ket_qua = pd.to_numeric(chuoi_sach, errors="coerce").astype(float)
    return ket_qua


# Thực thi mẫu phương pháp thứ hai
gia_sach_v2 = clean_price_v2(DEMO_LISTINGS_FULL["price"])
print(
    "Kiểm tra tính trùng khớp hai cách:",
    np.allclose(gia_sach.dropna(), gia_sach_v2.dropna()),
)

# Kết thúc lời giải
Giải thích Cách 2 ​

Mẫu r"[$,]" là một nhóm ký tự, khớp một dấu đô la hoặc một dấu phẩy. Trong nhóm này, $ được hiểu là ký tự cần xóa. regex=True yêu cầu pandas dùng mẫu đó để thực hiện thay thế. Một lời gọi ngắn hơn chưa chứng minh thời gian chạy ít hơn hai lần thay thế nguyên văn, vì regex cũng có chi phí xử lý.

pd.to_numeric(..., errors="coerce") chuyển chuỗi số thành số. Chuỗi không chuyển được sẽ trở thành giá trị khuyết. .astype(float) chuẩn hóa kiểu trả về theo đề. Khi làm dữ liệu thật, nên giữ thêm cờ chỉ những dòng vốn có dữ liệu nhưng chuyển đổi thất bại, để không nhầm lỗi định dạng với thiếu dữ liệu từ nguồn.

Lệnh np.allclose trong ví dụ so các giá hợp lệ của hai kết quả và trả True. Đây là kiểm tra số có dung sai, chưa kiểm tra đầy đủ chỉ mục và vị trí khuyết. Muốn xác nhận cả cấu trúc Series, cần đối chiếu thêm chỉ mục cùng mặt nạ .isna().

Output Cách 2 ​
Vị tríChuỗi ban đầuGiá số
0$45,647.0045647.0
1$18,500.0018500.0
2$1,120,000.001120000.0
3NoneNaN
4$59,000.0059000.0
5$25,990.5025990.5

Lệnh kiểm tra trong ví dụ in Kiểm tra tính trùng khớp hai cách: True.

So sánh hai cách ​

Cách 1 dừng khi gặp chuỗi số sai, giúp lộ rõ đầu vào không đúng quy định. Cách 2 cho phép tiếp tục xử lý nhưng phải theo dõi số giá trị bị chuyển thành khuyết. Cả hai đúng trên sáu chuỗi của bài. Chọn quy tắc báo lỗi theo yêu cầu dữ liệu trước khi cân nhắc tốc độ.


Bài 3: So sánh định dạng CSV và Parquet về kích thước tệp và bảo toàn kiểu dữ liệu ​

Mục tiêu: So sánh hai hình thức lưu trữ tệp phổ biến trong kỹ thuật dữ liệu: Định dạng văn bản CSV và định dạng nhị phân hướng cột Parquet.

Dữ liệu đầu vào: Đối tượng chỗ ở thu gọn gồm 5 cột kèm thông tin ngày giờ first_review.

Thao tác yêu cầu:

  1. Triển khai hàm save_formats(df: pd.DataFrame, folder) -> dict để lưu bảng thành hai tệp: t6.csv và t6.parquet trong thư mục được chỉ định.
  2. Đọc lại cả hai tệp vừa ghi (với pd.read_csv không dùng parse_dates, và pd.read_parquet).
  3. Trả về từ điển kết quả chứa kích thước tệp theo byte (size_csv, size_parquet) và từ điển kiểu dữ liệu của các cột sau khi đọc lại (dtypes_csv, dtypes_parquet).

Trường hợp biên cần chú ý:

  • Tệp Parquet đòi hỏi cài đặt engine nhị phân như pyarrow.
  • Tệp CSV không thể tự lưu trữ siêu dữ liệu kiểu thời gian, do đó khi đọc lại mà không cấu hình, cột ngày sẽ bị suy giảm thành kiểu chuỗi hoặc object.
Lời giải

Cách 1: Ghi hai định dạng rồi đọc lại để kiểm tra ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import os

import pandas as pd

import tempfile

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]

cot_ngay = ["first_review"]


def read_selected(path, columns: list, date_columns: list) -> pd.DataFrame:
    # Đọc tệp có chọn lọc cột và chuyển đổi ngày giờ
    df = pd.read_csv(
        path, usecols=columns, parse_dates=date_columns if date_columns else False
    )
    return df


bang_chon_loc = read_selected("listings_full.csv", cot_can_doc, cot_ngay)

# Bắt đầu lời giải


def save_formats(df: pd.DataFrame, folder) -> dict:
    duong_dan_thu_muc = Path(folder)
    tep_csv = duong_dan_thu_muc / "t6.csv"
    tep_parquet = duong_dan_thu_muc / "t6.parquet"

    # Ghi tệp không kèm chỉ số dòng
    df.to_csv(tep_csv, index=False)
    df.to_parquet(tep_parquet, index=False)

    # Đọc lại để kiểm tra kiểu dữ liệu sau tái nạp
    doc_lai_csv = pd.read_csv(tep_csv)
    doc_lai_pq = pd.read_parquet(tep_parquet)

    ket_qua = {
        "size_csv": os.path.getsize(tep_csv),
        "size_parquet": os.path.getsize(tep_parquet),
        "dtypes_csv": {cot: str(doc_lai_csv[cot].dtype) for cot in doc_lai_csv.columns},
        "dtypes_parquet": {
            cot: str(doc_lai_pq[cot].dtype) for cot in doc_lai_pq.columns
        },
    }
    return ket_qua


# Thực thi mẫu với thư mục tạm
with tempfile.TemporaryDirectory() as thu_muc_tam:
    thong_so_luu_tru = save_formats(bang_chon_loc, thu_muc_tam)
    print("Dung lượng CSV (bytes):", thong_so_luu_tru["size_csv"])
    print("Dung lượng Parquet (bytes):", thong_so_luu_tru["size_parquet"])
    print(
        "Kiểu first_review trong CSV:", thong_so_luu_tru["dtypes_csv"]["first_review"]
    )
    print(
        "Kiểu first_review trong Parquet:",
        thong_so_luu_tru["dtypes_parquet"]["first_review"],
    )

# Kết thúc lời giải
Giải thích Cách 1 ​

Path(folder) / "t6.csv" ghép tên tệp với thư mục đích. index=False yêu cầu không ghi thêm chỉ mục của thành một cột dữ liệu. Hàm giả thiết thư mục đã tồn tại, như thư mục do TemporaryDirectory tạo trong ví dụ.

.to_csv lưu giá trị dưới dạng văn bản nhưng không kèm đầy đủ kiểu của pandas. Vì read_csv trong bài không có parse_dates, cột ngày được đọc thành kiểu chuỗi hoặc object. .to_parquet dùng engine như pyarrow để lưu dữ liệu cùng thông tin kiểu, nên cột ngày của ví dụ giữ được độ phân giải khi đọc lại.

os.path.getsize trả kích thước tệp trên đĩa theo byte, khác với RAM của . Tệp sáu dòng quá nhỏ để kết luận Parquet luôn gọn hơn CSV. Phần và thông tin đầu cuối của Parquet có thể chiếm nhiều hơn dữ liệu được nén. Khả năng khôi phục kiểu cũng phụ thuộc kiểu nguồn, engine và phiên bản, không phải bảo đảm cho mọi đối tượng Python.

Output Cách 1 ​
Đại lượngCSVParquet
Kích thước trong lần kiểm tra303 byte3671 byte
Kiểu first_review sau đọcstrdatetime64[us]
Ghi chỉ mục thành cộtKhôngKhông

Số byte trên được chạy với pandas 3.0.5 và pyarrow 25.0.0. Phiên bản, metadata hoặc cách xuống dòng có thể làm kích thước tệp khác đi.

Cách 2: Tạo thư mục và dùng Path để đo kích thước ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import pandas as pd

import tempfile

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]

cot_ngay = ["first_review"]


def read_selected(path, columns: list, date_columns: list) -> pd.DataFrame:
    # Đọc tệp có chọn lọc cột và chuyển đổi ngày giờ
    df = pd.read_csv(
        path, usecols=columns, parse_dates=date_columns if date_columns else False
    )
    return df


bang_chon_loc = read_selected("listings_full.csv", cot_can_doc, cot_ngay)

# Bắt đầu lời giải


def save_formats_v2(df: pd.DataFrame, folder) -> dict:
    thu_muc = Path(folder)
    thu_muc.mkdir(parents=True, exist_ok=True)
    duong_dan_csv = thu_muc / "t6_v2.csv"
    duong_dan_pq = thu_muc / "t6_v2.parquet"

    # Ghi tệp định dạng nén tối ưu
    df.to_csv(duong_dan_csv, index=False)
    df.to_parquet(duong_dan_pq, index=False, compression="snappy")

    doc_csv = pd.read_csv(duong_dan_csv)
    doc_pq = pd.read_parquet(duong_dan_pq)

    return {
        "size_csv": duong_dan_csv.stat().st_size,
        "size_parquet": duong_dan_pq.stat().st_size,
        "dtypes_csv": {c: str(doc_csv[c].dtype) for c in doc_csv.columns},
        "dtypes_parquet": {c: str(doc_pq[c].dtype) for c in doc_pq.columns},
    }


# Thực thi mẫu phương pháp thứ hai
with tempfile.TemporaryDirectory() as thu_muc_tam_2:
    thong_so_v2 = save_formats_v2(bang_chon_loc, thu_muc_tam_2)
    print(
        "Tỷ lệ nén kích thước:",
        round(thong_so_v2["size_csv"] / thong_so_v2["size_parquet"], 2),
    )

# Kết thúc lời giải
Giải thích Cách 2 ​

.mkdir(parents=True, exist_ok=True) tạo cả thư mục cha còn thiếu và không báo lỗi chỉ vì thư mục đã tồn tại. .stat().st_size lấy kích thước tệp qua đối tượng Path, có cùng đơn vị byte với os.path.getsize.

compression="snappy" chọn cách nén cho Parquet. Snappy là một lựa chọn cân bằng giữa nén và tốc độ, nhưng không hứa cho tệp nhỏ nhất. Đoạn mã vẫn gọi read_parquet để đọc bảng rồi xem các .dtype. Nó không chỉ đọc phần metadata và vẫn cần bộ nhớ cho dữ liệu đã nạp.

Tỷ số in ra là kích thước CSV chia kích thước Parquet. Giá trị nhỏ hơn 1 ở DEMO cho biết tệp CSV nhỏ hơn trong lần ghi này. Tên t6_v2.csv và t6_v2.parquet giúp giữ riêng các tệp của Cách 2, không ảnh hưởng nội dung bảng.

Output Cách 2 ​
Đại lượngKết quả lần kiểm tra
CSV / Parquet, làm tròn 2 chữ số0.08
Kích thước CSV303 byte
Kích thước Parquet3671 byte
Kiểu ngày khi đọc Parquetdatetime64[us]

So sánh hai cách ​

Hai cách cùng kiểm tra kích thước và kiểu sau khi đọc lại. Cách 2 bổ sung tạo thư mục và thao tác đường dẫn thống nhất. Với dữ liệu đủ lớn, Parquet có thể có lợi về nén và đọc chọn cột, nhưng phải đo trên trường hợp thực tế. Mức nén của sáu dòng chỉ dùng để hiểu cách đo, không dùng làm tỷ lệ dự báo.


Bài 4: Trích xuất dữ liệu API thời tiết lịch sử và lưu trữ phản hồi thô ​

Mục tiêu: Xây dựng quy trình thu thập dữ liệu từ thời tiết lịch sử của Open-Meteo, kiểm tra tính hợp lệ của đơn vị đo lường và thực hiện nguyên tắc lưu trữ phản hồi thô để phục vụ tái lập quy trình.

Dữ liệu đầu vào:

  • Đối tượng từ điển phản hồi từ thời tiết payload chứa thông tin kinh độ, vĩ độ, múi giờ và cấu trúc daily gồm các danh sách giá trị theo ngày.
  • Đơn vị đo nhiệt độ tối đa được quy định trong daily_units.

Thao tác yêu cầu:

  1. Triển khai hàm weather_table(payload: dict, path) -> pd.DataFrame để ghi toàn bộ cấu trúc phản hồi gốc ra tệp tại đường dẫn path.
  2. Chuyển đổi khối dữ liệu daily trong phản hồi thành một với hai cột: time (chuỗi ngày) và temperature_2m_max (nhiệt độ tối đa trong ngày).
  3. Triển khai hàm gọi mạng an toàn có tham số kiểm tra ngoại tuyến để không làm gián đoạn bài thực hành khi không có kết nối mạng.

Trường hợp biên cần chú ý:

  • Phản hồi thô phải được ghi với mã hóa UTF-8 và tham số ensure_ascii=False để giữ nguyên các ký tự đặc biệt.
  • Kiểm tra tính nhất quán giữa đơn vị quy ước và dữ liệu thực tế: Đơn vị phải là °C.
Lời giải

Cách 1: Lưu JSON gốc rồi chuyển phần daily thành bảng ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import json

import pandas as pd

import tempfile

DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

# Bắt đầu lời giải


def weather_table(payload: dict, path) -> pd.DataFrame:
    duong_dan_tep = Path(path)
    duong_dan_tep.parent.mkdir(parents=True, exist_ok=True)

    # Ghi toàn bộ phản hồi thô ra đĩa
    with open(duong_dan_tep, "w", encoding="utf-8") as f:
        json.dump(payload, f, ensure_ascii=False, indent=2)

    # Chuyển đổi phần daily thành bảng dữ liệu
    bang_thoi_tiet = pd.DataFrame(payload["daily"])
    return bang_thoi_tiet


# Thực thi mẫu với dữ liệu giả lập DEMO_WEATHER
with tempfile.TemporaryDirectory() as thu_muc_tam:
    tep_thoi_tiet = Path(thu_muc_tam) / "weather_scl_2025-01.json"
    df_thoi_tiet = weather_table(DEMO_WEATHER, tep_thoi_tiet)
    print("Số ngày trong bảng thời tiết:", len(df_thoi_tiet))
    print(
        "Nhiệt độ tối đa trung bình:",
        round(df_thoi_tiet["temperature_2m_max"].mean(), 2),
    )

# Kết thúc lời giải
Giải thích Cách 1 ​

Path(path).parent lấy thư mục chứa tệp. .mkdir(parents=True, exist_ok=True) tạo nơi lưu nếu chưa có. Khối with open(..., encoding="utf-8") quản lý việc mở và đóng tệp ngay cả khi có lỗi trong quá trình ghi.

json.dump(payload, f, ensure_ascii=False, indent=2) lưu toàn bộ từ điển đã nhận, bao gồm tọa độ, múi giờ, đơn vị và các dãy ngày. ensure_ascii=False giữ ký tự như ° dễ đọc trong tệp UTF-8. indent=2 chỉ định cách trình bày thụt lề. Đây là bản lưu cấu trúc đã giải mã, không phải bản sao từng byte của phản hồi HTTP.

pd.DataFrame(payload["daily"]) tạo mỗi danh sách thành một cột. Hai danh sách phải có cùng độ dài để ghép đúng nhiệt độ với ngày. Cách cơ bản này giả thiết cấu trúc và đơn vị đã hợp lệ. Cần bước kiểm tra như Cách 2 khi phản hồi đến từ nguồn có thể thay đổi.

Output Cách 1 ​
timetemperature_2m_max (°C)
2025-01-0131.4
2025-01-0233.1
2025-01-0329.1
2025-01-0432.8
2025-01-0530.2
2025-01-0630.0
2025-01-0728.5
2025-01-0831.8
2025-01-0930.9
2025-01-1027.6

Bảng có 10 ngày. Trung bình các nhiệt độ tối đa là 30.54 °C.

Cách 2: Kiểm tra đơn vị và cho phép dùng phản hồi ngoại tuyến ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import json

import pandas as pd

DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

# Bắt đầu lời giải


def fetch_weather_archive(
    latitude: float,
    longitude: float,
    start_date: str,
    end_date: str,
    offline_payload: dict = None,
) -> dict:
    # Nếu được cung cấp payload ngoại tuyến, sử dụng trực tiếp không qua mạng
    if offline_payload is not None:
        return offline_payload

    # Trường hợp gọi mạng thật qua thư viện urllib tiêu chuẩn
    import urllib.request
    import urllib.parse

    tham_so = {
        "latitude": latitude,
        "longitude": longitude,
        "start_date": start_date,
        "end_date": end_date,
        "daily": "temperature_2m_max",
        "timezone": "America/Santiago",
    }
    url = f"https://archive-api.open-meteo.com/v1/archive?{urllib.parse.urlencode(tham_so)}"
    yeu_cau = urllib.request.Request(
        url, headers={"User-Agent": "StudyHub-DataLab/1.0"}
    )
    with urllib.request.urlopen(yeu_cau, timeout=30) as phan_hoi:
        du_lieu_json = json.loads(phan_hoi.read().decode("utf-8"))
    return du_lieu_json


def weather_table_v2(payload: dict, path, metadata: dict = None) -> pd.DataFrame:
    # Xác thực đơn vị đo lường nhiệt độ
    don_vi = payload.get("daily_units", {}).get("temperature_2m_max")
    if don_vi != "°C":
        raise ValueError(
            f"Đơn vị đo không phù hợp, kỳ vọng '°C', nhận được: '{don_vi}'"
        )

    tep = Path(path)
    tep.parent.mkdir(parents=True, exist_ok=True)
    tep.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")

    if metadata:
        tep_meta = tep.with_suffix(".meta.json")
        tep_meta.write_text(
            json.dumps(metadata, ensure_ascii=False, indent=2), encoding="utf-8"
        )

    return pd.DataFrame(payload["daily"])


# Thực thi mẫu với chế độ ngoại tuyến an toàn
payload_thu_thap = fetch_weather_archive(
    -33.45, -70.66, "2025-01-01", "2025-01-10", offline_payload=DEMO_WEATHER
)
df_thoi_tiet_v2 = weather_table_v2(
    payload_thu_thap, "weather_v2.json", metadata={"nguon": "Open-Meteo Archive"}
)
print(
    "Kiểm tra đơn vị thành công:", payload_thu_thap["daily_units"]["temperature_2m_max"]
)

# Kết thúc lời giải
Giải thích Cách 2 ​

offline_payload cho phép chạy lại bài bằng dữ liệu mẫu mà không gọi mạng. Nếu không truyền đối số đó, urlencode(tham_so) mã hóa các tham số trong URL, rồi urlopen(..., timeout=30) thực hiện yêu cầu mạng. json.loads chuyển nội dung phản hồi đã giải mã UTF-8 thành từ điển Python.

Trong weather_table_v2, hai lần .get đọc đơn vị mà không gây KeyError nếu thiếu trường. Nếu đơn vị không đúng °C, hàm phát sinh ValueError trước khi dùng nhiệt độ để tính toán. write_text lưu bản có thể đọc lại. Nếu có metadata, .with_suffix(".meta.json") tạo một tệp thông tin phụ cạnh bản lưu chính.

Ví dụ truyền DEMO_WEATHER nên các con số dưới đây vẫn là dữ liệu giả lập. Chuỗi nguồn trong metadata không biến fixture thành quan sát thời tiết thực. Bảng trả về có cùng hai cột như Cách 1 và vẫn giữ time là chuỗi để ghép ở những bài sau.

Output Cách 2 ​
timetemperature_2m_max (°C)
2025-01-0131.4
2025-01-0233.1
2025-01-0329.1
2025-01-0432.8
2025-01-0530.2
2025-01-0630.0
2025-01-0728.5
2025-01-0831.8
2025-01-0930.9
2025-01-1027.6

Dòng xác nhận của ví dụ là Kiểm tra đơn vị thành công: °C.

So sánh hai cách ​

Cách 1 mở rõ thao tác ghi tệp và lấy phần dữ liệu cần dùng. Cách 2 thêm kiểm tra đơn vị, metadata và lựa chọn nguồn ngoại tuyến để dễ tái lập. Nếu cần kiểm soát đầy đủ hơn, phải kiểm tra cả độ dài mảng, tên trường, phạm vi ngày và trạng thái phản hồi. Hai cách đều không được dùng dữ liệu mẫu để suy ra khí hậu thực tế.


Bài 5: Thống kê số lượng đánh giá theo ngày và xử lý ngày không có đánh giá ​

Mục tiêu: Lọc các dòng đánh giá theo một khoảng thời gian cố định, gom nhóm theo từng ngày và tạo bảng đếm tần suất phục vụ ghép nối dữ liệu.

Dữ liệu đầu vào:

  • Bảng đánh giá rv chứa cột date kiểu datetime64.
  • Hai mốc thời gian lọc dạng chuỗi: start và end (định dạng YYYY-MM-DD).

Thao tác yêu cầu:

  1. Triển khai hàm reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame để lọc các dòng thỏa mãn start <= date <= end (bao gồm cả hai đầu khoảng).
  2. Định dạng ngày thành chuỗi chuẩn YYYY-MM-DD bằng phương thức dt.strftime("%Y-%m-%d").
  3. Đếm số lượng dòng đánh giá cho từng ngày, sắp xếp theo thời gian tăng dần và thiết lập lại chỉ mục số nguyên 0, 1, 2....

Trường hợp biên cần chú ý:

  • Những ngày nằm trong khoảng lọc nhưng không có lượt đánh giá nào phát sinh (ví dụ ngày 2025-01-05 trong dữ liệu mẫu) sẽ không xuất hiện trong kết quả của phép gom nhóm này.
  • Giữ nguyên dữ liệu của bảng rv ban đầu, không thay đổi chỉ mục hay giá trị của đầu vào.
Lời giải

Cách 1: Lọc khoảng ngày rồi nhóm các dòng có cùng ngày ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

# Bắt đầu lời giải


def reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    # Bản sao an toàn để tránh cảnh báo Copy-on-Write
    du_lieu = rv.copy()
    ngay_bat_dau = pd.to_datetime(start)
    ngay_ket_thuc = pd.to_datetime(end)

    # Bước 1: Lọc các dòng nằm trong khoảng ngày yêu cầu
    dieu_kien = (du_lieu["date"] >= ngay_bat_dau) & (du_lieu["date"] <= ngay_ket_thuc)
    du_lieu_loc = du_lieu.loc[dieu_kien]

    # Bước 2: Gom nhóm theo chuỗi ngày và đếm số lượng
    chuoi_ngay = du_lieu_loc["date"].dt.strftime("%Y-%m-%d")
    dem_ngay = du_lieu_loc.groupby(chuoi_ngay).size().reset_index(name="so_review")

    # Bước 3: Đổi tên cột đồng nhất và sắp xếp thời gian tăng dần
    ket_qua = (
        dem_ngay.rename(columns={"date": "time"})
        .sort_values("time")
        .reset_index(drop=True)
    )
    return ket_qua


# Thực thi mẫu với khoảng ngày từ 2025-01-01 đến 2025-01-10
thong_ke_ngay = reviews_per_day(DEMO_REVIEWS, "2025-01-01", "2025-01-10")
print("Tổng số ngày có đánh giá:", len(thong_ke_ngay))
print("Tổng lượt đánh giá:", thong_ke_ngay["so_review"].sum())

# Kết thúc lời giải
Giải thích Cách 1 ​

pd.to_datetime(start) và pd.to_datetime(end) tạo hai mốc để so sánh với cột date. Hai điều kiện dùng >= và <=, rồi ghép bằng &. Mỗi vế phải có ngoặc vì đây là phép toán trên toàn Series, không phải toán tử and của một giá trị boolean.

Với dữ liệu của bài đều ở mốc đầu ngày, điều kiện giữ cả ngày đầu và ngày cuối. Nếu cột chứa cả giờ, end="2025-01-10" chỉ biểu diễn nửa đêm bắt đầu ngày đó. Muốn bao phủ trọn ngày cuối trong một bài khác, cần chọn khoảng thời gian phù hợp thay vì cho rằng định dạng ngày đã giải quyết vấn đề giờ và múi giờ.

.dt.strftime("%Y-%m-%d") tạo khóa ngày dạng chuỗi ISO. groupby(chuoi_ngay).size() đếm số dòng của từng khóa, còn reset_index(name="so_review") đưa khóa ra thành cột. Đổi tên sang time, sắp theo ngày rồi đặt lại chỉ mục tạo đúng bảng hai cột theo đề. Ngày 05/01 không xuất hiện vì không có dòng đánh giá nào của ngày ấy.

Output Cách 1 ​
timeso_review
2025-01-013
2025-01-025
2025-01-032
2025-01-046
2025-01-064
2025-01-077
2025-01-081
2025-01-095
2025-01-103

Kết quả có 9 ngày và tổng so_review bằng 36.

Cách 2: Đếm tần suất trực tiếp trên Series ngày ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)


def reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    # Bản sao an toàn để tránh cảnh báo Copy-on-Write
    du_lieu = rv.copy()
    ngay_bat_dau = pd.to_datetime(start)
    ngay_ket_thuc = pd.to_datetime(end)

    # Bước 1: Lọc các dòng nằm trong khoảng ngày yêu cầu
    dieu_kien = (du_lieu["date"] >= ngay_bat_dau) & (du_lieu["date"] <= ngay_ket_thuc)
    du_lieu_loc = du_lieu.loc[dieu_kien]

    # Bước 2: Gom nhóm theo chuỗi ngày và đếm số lượng
    chuoi_ngay = du_lieu_loc["date"].dt.strftime("%Y-%m-%d")
    dem_ngay = du_lieu_loc.groupby(chuoi_ngay).size().reset_index(name="so_review")

    # Bước 3: Đổi tên cột đồng nhất và sắp xếp thời gian tăng dần
    ket_qua = (
        dem_ngay.rename(columns={"date": "time"})
        .sort_values("time")
        .reset_index(drop=True)
    )
    return ket_qua


thong_ke_ngay = reviews_per_day(DEMO_REVIEWS, "2025-01-01", "2025-01-10")

# Bắt đầu lời giải


def reviews_per_day_v2(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    ngay_dau = pd.to_datetime(start)
    ngay_cuoi = pd.to_datetime(end)

    # Lọc Series ngày trực tiếp
    cot_ngay_loc = rv.loc[(rv["date"] >= ngay_dau) & (rv["date"] <= ngay_cuoi), "date"]

    # Đếm tần suất xuất hiện của từng chuỗi ngày
    dem_tan_suat = cot_ngay_loc.dt.strftime("%Y-%m-%d").value_counts().sort_index()

    # Chuyển đổi Series thành DataFrame hai cột chuẩn
    ket_qua = dem_tan_suat.reset_index()
    ket_qua.columns = ["time", "so_review"]
    return ket_qua


# Thực thi mẫu phương pháp thứ hai
thong_ke_ngay_v2 = reviews_per_day_v2(DEMO_REVIEWS, "2025-01-01", "2025-01-10")
print("Kiểm tra tính trùng khớp kết quả:", thong_ke_ngay.equals(thong_ke_ngay_v2))

# Kết thúc lời giải
Giải thích Cách 2 ​

rv.loc[điều_kiện, "date"] chỉ lấy cột ngày của các dòng trong khoảng, nên không sao chép toàn bộ các cột của bảng như bước rv.copy() ở Cách 1. Đầu vào vẫn được giữ nguyên vì các bước sau chỉ tạo kết quả mới.

Sau khi định dạng ngày, .value_counts() đếm số lần mỗi chuỗi xuất hiện. Kết quả mặc định xếp theo tần suất, vì vậy phải thêm .sort_index() để đưa các ngày về thứ tự thời gian. Với chuỗi ISO có tháng và ngày đủ hai chữ số, thứ tự chữ cái cũng là thứ tự ngày.

reset_index() chuyển Series tần suất thành . Gán ket_qua.columns xác định rõ tên hai cột time và so_review, tránh phụ thuộc tên mặc định theo phiên bản pandas. Ngày không có sự kiện vẫn không tự được sinh thêm.

Output Cách 2 ​
timeso_review
2025-01-013
2025-01-025
2025-01-032
2025-01-046
2025-01-064
2025-01-077
2025-01-081
2025-01-095
2025-01-103

Ví dụ in Kiểm tra tính trùng khớp kết quả: True.

So sánh hai cách ​

Cách 1 mô tả riêng lọc, tạo khóa và tổng hợp nên dễ theo dõi. Cách 2 chọn ngay cột cần dùng và dùng thao tác đếm chuyên dụng, giảm các bản sao không cần thiết của bảng rộng. Hai cách đều trả các ngày có đánh giá. Lập lịch ngày liên tục rồi điền 0 là một yêu cầu khác và phải được nêu rõ.


Bài 6: Ghép nối thời tiết và đánh giá bằng phép ghép trái và đo lường tương quan ​

Mục tiêu: Ghép nối hai nguồn dữ liệu không đồng nhất theo trục thời gian bằng phép ghép trái (Left Join), xử lý các giá trị khuyết thiếu và đo lường hệ số tương quan tuyến tính Pearson.

Dữ liệu đầu vào:

  • Bảng thời tiết weather chứa các cột time (chuỗi ngày) và temperature_2m_max (nhiệt độ tối đa).
  • Bảng thống kê đánh giá per_day tạo ra từ Bài 5 chứa cột time và so_review.

Thao tác yêu cầu:

  1. Triển khai hàm join_weather(weather: pd.DataFrame, per_day: pd.DataFrame) -> dict sử dụng phép ghép trái merge(..., on="time", how="left") để giữ nguyên toàn bộ các ngày có trong bảng thời tiết.
  2. Xác định số lượng ngày không có lượt đánh giá nào (các dòng có giá trị NaN ở cột so_review).
  3. Tính hệ số tương quan Pearson giữa cột nhiệt độ tối đa temperature_2m_max và số lượt đánh giá so_review.
  4. Trả về từ điển kết quả chứa sau ghép nối gop, số ngày khuyết so_thieu, và hệ số tương quan he_so.

Trường hợp biên cần chú ý:

  • Theo yêu cầu của bài này, giữ nguyên NaN khi phép ghép không tìm thấy ngày tương ứng. Không tự đổi thành 0, vì như vậy ta đang thay đổi tập cặp quan sát dùng để tính tương quan.
Lời giải

Cách 1: Ghép theo cột ngày và tính corr trên các cặp hợp lệ ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)


def reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    # Bản sao an toàn để tránh cảnh báo Copy-on-Write
    du_lieu = rv.copy()
    ngay_bat_dau = pd.to_datetime(start)
    ngay_ket_thuc = pd.to_datetime(end)

    # Bước 1: Lọc các dòng nằm trong khoảng ngày yêu cầu
    dieu_kien = (du_lieu["date"] >= ngay_bat_dau) & (du_lieu["date"] <= ngay_ket_thuc)
    du_lieu_loc = du_lieu.loc[dieu_kien]

    # Bước 2: Gom nhóm theo chuỗi ngày và đếm số lượng
    chuoi_ngay = du_lieu_loc["date"].dt.strftime("%Y-%m-%d")
    dem_ngay = du_lieu_loc.groupby(chuoi_ngay).size().reset_index(name="so_review")

    # Bước 3: Đổi tên cột đồng nhất và sắp xếp thời gian tăng dần
    ket_qua = (
        dem_ngay.rename(columns={"date": "time"})
        .sort_values("time")
        .reset_index(drop=True)
    )
    return ket_qua


thong_ke_ngay = reviews_per_day(DEMO_REVIEWS, "2025-01-01", "2025-01-10")

DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

df_thoi_tiet = pd.DataFrame(DEMO_WEATHER["daily"])

# Bắt đầu lời giải


def join_weather(weather: pd.DataFrame, per_day: pd.DataFrame) -> dict:
    # Thực hiện ghép trái theo cột khóa time
    gop = weather.merge(per_day, on="time", how="left")

    # Đếm số lượng giá trị khuyết thiếu ở cột đánh giá
    so_thieu = int(gop["so_review"].isna().sum())

    # Tính toán hệ số tương quan Pearson
    he_so = float(gop["temperature_2m_max"].corr(gop["so_review"]))

    return {
        "gop": gop,
        "so_thieu": so_thieu,
        "he_so": he_so,
    }


# Thực thi mẫu ghép nối bảng thời tiết và bảng đánh giá
ket_qua_ghep = join_weather(df_thoi_tiet, thong_ke_ngay)
print("Số dòng sau ghép:", len(ket_qua_ghep["gop"]))
print("Số ngày khuyết đánh giá:", ket_qua_ghep["so_thieu"])
print("Hệ số tương quan Pearson:", round(ket_qua_ghep["he_so"], 3))

# Kết thúc lời giải
Giải thích Cách 1 ​

weather.merge(per_day, on="time", how="left") giữ các ngày của bảng thời tiết rồi tìm số đánh giá theo cùng khóa. Ví dụ có mỗi ngày đúng một dòng ở mỗi bảng nên kết quả vẫn có 10 dòng. Nếu bảng đếm chứa khóa ngày trùng, phép ghép có thể tăng số dòng, vì vậy tính duy nhất của khóa cần được kiểm tra trong dữ liệu thật.

.isna().sum() trên so_review cho biết một ngày không khớp. Do có ô khuyết, cột số đếm có thể hiển thị bằng số thực như 3.0. Giá trị ấy vẫn có nghĩa là ba đánh giá, không phải phép đếm phân số.

Series.corr mặc định tính hệ số Pearson và bỏ những cặp có ít nhất một giá trị khuyết. Ở đây có chín cặp được dùng, còn ngày 05/01 không tham gia. Hệ số khoảng 0.084 chỉ mô tả quan hệ tuyến tính yếu trong chín cặp của fixture. Nó không chứng minh nhiệt độ gây ra hoặc không gây ra thay đổi số đánh giá.

Output Cách 1 ​
timeNhiệt độ tối đa (°C)so_review
2025-01-0131.43.0
2025-01-0233.15.0
2025-01-0329.12.0
2025-01-0432.86.0
2025-01-0530.2NaN
2025-01-06304.0
2025-01-0728.57.0
2025-01-0831.81.0
2025-01-0930.95.0
2025-01-1027.63.0

Từ điển có so_thieu = 1. Giá trị he_so làm tròn ba chữ số là 0.084.

Cách 2: Ghép bằng chỉ mục rồi kiểm tra số cặp ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)


def reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    # Bản sao an toàn để tránh cảnh báo Copy-on-Write
    du_lieu = rv.copy()
    ngay_bat_dau = pd.to_datetime(start)
    ngay_ket_thuc = pd.to_datetime(end)

    # Bước 1: Lọc các dòng nằm trong khoảng ngày yêu cầu
    dieu_kien = (du_lieu["date"] >= ngay_bat_dau) & (du_lieu["date"] <= ngay_ket_thuc)
    du_lieu_loc = du_lieu.loc[dieu_kien]

    # Bước 2: Gom nhóm theo chuỗi ngày và đếm số lượng
    chuoi_ngay = du_lieu_loc["date"].dt.strftime("%Y-%m-%d")
    dem_ngay = du_lieu_loc.groupby(chuoi_ngay).size().reset_index(name="so_review")

    # Bước 3: Đổi tên cột đồng nhất và sắp xếp thời gian tăng dần
    ket_qua = (
        dem_ngay.rename(columns={"date": "time"})
        .sort_values("time")
        .reset_index(drop=True)
    )
    return ket_qua


thong_ke_ngay = reviews_per_day(DEMO_REVIEWS, "2025-01-01", "2025-01-10")

DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

df_thoi_tiet = pd.DataFrame(DEMO_WEATHER["daily"])

# Bắt đầu lời giải


def join_weather_v2(weather: pd.DataFrame, per_day: pd.DataFrame) -> dict:
    # Đặt time làm chỉ mục để ghép nối băm nhanh
    w_idx = weather.set_index("time")
    p_idx = per_day.set_index("time")

    # Ghép nối trái trên chỉ mục
    gop_idx = w_idx.join(p_idx, how="left").reset_index()

    so_thieu = int(gop_idx["so_review"].isna().sum())

    # Kiểm tra điều kiện tối thiểu để tính hệ số tương quan
    cac_dong_hop_le = gop_idx.dropna(subset=["temperature_2m_max", "so_review"])
    if len(cac_dong_hop_le) >= 2:
        he_so = float(
            cac_dong_hop_le["temperature_2m_max"].corr(cac_dong_hop_le["so_review"])
        )
    else:
        he_so = float("nan")

    return {
        "gop": gop_idx,
        "so_thieu": so_thieu,
        "he_so": he_so,
    }


# Thực thi mẫu phương pháp thứ hai
ket_qua_ghep_v2 = join_weather_v2(df_thoi_tiet, thong_ke_ngay)
print("Hệ số tương quan cách 2:", round(ket_qua_ghep_v2["he_so"], 3))

# Kết thúc lời giải
Giải thích Cách 2 ​

set_index("time") đưa khóa ngày thành chỉ mục. .join(p_idx, how="left") ghép theo các chỉ mục ấy, sau đó reset_index() đưa ngày về cột. Cách viết phù hợp khi bảng đã dùng ngày làm chỉ mục. Việc đổi chỉ mục riêng để ghép chưa tự bảo đảm nhanh hơn merge.

dropna(subset=["temperature_2m_max", "so_review"]) xác định tường minh những dòng có đủ hai số để tính tương quan. Nếu còn ít hơn hai dòng, hàm trả float("nan"). Kể cả có hai dòng trở lên, nếu một biến không thay đổi thì hệ số Pearson vẫn chưa xác định và pandas trả NaN.

Cột so_review trong gop không được điền 0. Quy tắc này giữ đúng yêu cầu tính trên cặp quan sát có sẵn. Với một log sự kiện đầy đủ, lập lịch liên tục để diễn giải ngày không có sự kiện là 0 có thể hợp lý, nhưng sẽ tạo tập cặp và hệ số khác.

Output Cách 2 ​
timeNhiệt độ tối đa (°C)so_review
2025-01-0131.43.0
2025-01-0233.15.0
2025-01-0329.12.0
2025-01-0432.86.0
2025-01-0530.2NaN
2025-01-06304.0
2025-01-0728.57.0
2025-01-0831.81.0
2025-01-0930.95.0
2025-01-1027.63.0

Cách 2 cũng có 10 dòng, một số đếm khuyết và hệ số làm tròn 0.084.

So sánh hai cách ​

Hai cách khác cách biểu diễn khóa ghép. Cách 1 dễ dùng với bảng còn cột ngày, Cách 2 thuận tiện với bảng đã có chỉ mục ngày và mở rõ kiểm tra số cặp. Trước khi diễn giải hệ số, cần xem số cặp hợp lệ, độ biến thiên, kỳ quan sát và dữ liệu khuyết. Tương quan không đo số đặt phòng và không xác lập quan hệ nhân quả.


Bài 7: Truy vấn trực tiếp trên tệp CSV và đếm đánh giá theo năm với DuckDB ​

Mục tiêu: Sử dụng DuckDB để thực thi các truy vấn SQL trực tiếp trên tệp văn bản CSV mà không cần nạp toàn bộ dữ liệu vào đối tượng của pandas.

Dữ liệu đầu vào: Đường dẫn tệp CSV đánh giá reviews.csv chứa các cột listing_id và date.

Thao tác yêu cầu:

  1. Triển khai hàm sql_reviews_by_year(reviews_path) -> pd.DataFrame thực hiện một câu truy vấn SQL DuckDB duy nhất để trích xuất năm từ cột ngày và đếm tổng số dòng đánh giá cho từng năm.
  2. Câu truy vấn sử dụng hàm year(date) để lấy giá trị năm, gom nhóm bằng GROUP BY nam và sắp xếp tăng dần theo năm ORDER BY nam.
  3. Trả về kết quả dưới dạng một gồm đúng hai cột: nam (kiểu số nguyên) và n (số lượng đánh giá kiểu số nguyên).

Trường hợp biên cần chú ý:

  • Đường dẫn trong ví dụ cơ bản là đường dẫn tệp cục bộ đã biết. Dấu gạch xuôi / thống nhất cách viết đường dẫn nhưng không thoát ký tự SQL. Dùng tham số truy vấn khi truyền đường dẫn có thể chứa ký tự đặc biệt.
  • Kết quả đếm từ SQL phải khớp tuyệt đối với kết quả thống kê bằng logic Python thuần hoặc pandas trên cùng tập dữ liệu.
Lời giải

Cách 1: Đếm theo năm trực tiếp trên tệp bằng DuckDB ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

# Bắt đầu lời giải


def sql_reviews_by_year(reviews_path) -> pd.DataFrame:
    # Chuẩn hóa đường dẫn tệp sang dạng chuỗi posix
    duong_dan = Path(reviews_path).as_posix()

    # Câu truy vấn SQL đếm theo năm
    cau_lenh = f"""
    SELECT year(date) AS nam, count(*) AS n
    FROM '{duong_dan}'
    GROUP BY nam
    ORDER BY nam
    """
    bang_ket_qua = duckdb.sql(cau_lenh).df()
    return bang_ket_qua


# Thực thi mẫu với tệp reviews.csv
bang_dem_nam = sql_reviews_by_year("reviews.csv")
print("Kết quả đếm theo năm qua DuckDB:")
print(bang_dem_nam)

# Kết thúc lời giải
Giải thích Cách 1 ​

Path(reviews_path).as_posix() chuẩn hóa dấu phân cách đường dẫn thành /. Trong câu SQL, FROM 'đường_dẫn' cho DuckDB đọc tệp CSV, còn .df() chuyển bảng kết quả thành pandas. Không cần tạo trước một DataFrame chứa toàn bộ tệp chỉ để chạy phép tổng hợp này.

year(date) AS nam lấy năm và đặt tên cột. COUNT(*) AS n đếm mọi dòng của từng năm sau GROUP BY nam. ORDER BY nam là điều tạo thứ tự năm tăng dần trong đầu ra. Không nên dựa vào thứ tự tình cờ của phép nhóm.

Đọc CSV vẫn cần quét dữ liệu liên quan, nên không thể xem đây là lời hứa chỉ đọc vài khối byte hay không dùng RAM. Đoạn ghép chuỗi SQL cơ bản giả thiết đường dẫn cục bộ hợp lệ và không chứa dấu nháy đơn. as_posix() không thoát ký tự SQL. Cách 2 dùng tham số để truyền giá trị đường dẫn chắc chắn hơn.

Output Cách 1 ​
namn
20233
20246
202540
20262

Cách 2: Truyền đường dẫn bằng tham số và khai báo kiểu đầu ra ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)


def sql_reviews_by_year(reviews_path) -> pd.DataFrame:
    # Chuẩn hóa đường dẫn tệp sang dạng chuỗi posix
    duong_dan = Path(reviews_path).as_posix()

    # Câu truy vấn SQL đếm theo năm
    cau_lenh = f"""
    SELECT year(date) AS nam, count(*) AS n
    FROM '{duong_dan}'
    GROUP BY nam
    ORDER BY nam
    """
    bang_ket_qua = duckdb.sql(cau_lenh).df()
    return bang_ket_qua


bang_dem_nam = sql_reviews_by_year("reviews.csv")

# Bắt đầu lời giải


def sql_reviews_by_year_v2(reviews_path) -> pd.DataFrame:
    chuoi_tep = str(Path(reviews_path).resolve())

    # Tạo kết nối bộ nhớ độc lập
    ket_noi = duckdb.connect(database=":memory:")

    # Thực thi truy vấn với tham số vị trí an toàn
    truy_van = """
    SELECT CAST(year(date) AS INTEGER) AS nam,
           CAST(count(*) AS BIGINT) AS n
    FROM read_csv_auto(?)
    GROUP BY nam
    ORDER BY nam ASC
    """
    ket_qua = ket_noi.execute(truy_van, [chuoi_tep]).df()
    ket_noi.close()
    return ket_qua


# Thực thi mẫu phương pháp thứ hai
bang_dem_nam_v2 = sql_reviews_by_year_v2("reviews.csv")
print("Kiểm tra khớp tuyệt đối giữa hai cách:", bang_dem_nam.equals(bang_dem_nam_v2))

# Kết thúc lời giải
Giải thích Cách 2 ​

duckdb.connect(database=":memory:") mở một kết nối cơ sở dữ liệu trong bộ nhớ. Trong read_csv_auto(?), dấu ? là vị trí nhận giá trị. execute(truy_van, [chuoi_tep]) truyền đường dẫn riêng với mã SQL, giúp xử lý đường dẫn có dấu cách hoặc ký tự đặc biệt mà không tự thêm dấu nháy.

CAST(year(date) AS INTEGER) chuẩn hóa năm thành số nguyên 32 bit trong DuckDB. CAST(count(*) AS BIGINT) dùng số nguyên 64 bit cho số đếm. .df() xuất kết quả, rồi .close() đóng kết nối sau khi đã lấy bảng.

Trên đoạn mã hiện tại, hai bảng có cùng năm và số đếm nhưng bang_dem_nam.equals(bang_dem_nam_v2) in False. Nguyên nhân là Cách 1 trả cột năm kiểu int64, Cách 2 ép về int32. DataFrame.equals so cả kiểu dữ liệu tương ứng, nên bằng nhau về giá trị chưa đủ. Muốn kiểm tra giống nhau, hãy chuẩn hóa kiểu rồi mới so. Nếu chỉ kiểm tra giá trị, cần nêu rõ phép đối chiếu cho phép khác kiểu.

Output Cách 2 ​
namn
20233
20246
202540
20262

Bản in đối chiếu của hai đoạn mã chưa chuẩn hóa cùng kiểu là Kiểm tra khớp tuyệt đối giữa hai cách: False.

So sánh hai cách ​

Cách 1 gọn cho truy vấn thử với tệp cục bộ đã biết. Cách 2 phù hợp hơn khi đường dẫn đến từ đối số hàm và cần kiểu đầu ra ổn định. Hai cách không khác số đánh giá đã đếm. Trong báo cáo kiểm thử, cần tách kiểm tra giá trị với kiểm tra thay vì coi mọi False từ .equals là sai phép tổng hợp.


Bài 8: Phân biệt COUNT(*) và COUNT(cột) trong DuckDB trên tệp Parquet ​

Mục tiêu: Nắm vững sự khác biệt ngữ nghĩa căn bản trong chuẩn SQL giữa hàm đếm bản ghi COUNT(*) và hàm đếm giá trị khác null COUNT(tên_cột) khi xử lý dữ liệu lưu trữ dưới định dạng Parquet.

Dữ liệu đầu vào: Tệp dữ liệu chỗ ở Parquet listings.parquet chứa các cột room_type và first_review.

Thao tác yêu cầu:

  1. Triển khai hàm sql_count_listings_and_reviews(parquet_path) -> pd.DataFrame thực hiện truy vấn SQL trên tệp Parquet.
  2. Với từng loại phòng room_type, tính toán hai đại lượng thống kê:
    • Tổng số chỗ ở: COUNT(*) AS so_cho_o.
    • Số lượng chỗ ở đã từng nhận ít nhất một đánh giá: COUNT(first_review) AS so_co_danh_gia.
  3. Sắp xếp kết quả giảm dần theo tổng số chỗ ở so_cho_o, nếu bằng nhau thì sắp tăng dần theo room_type.

Trường hợp biên cần chú ý:

  • Những chỗ ở mới niêm yết chưa có khách đánh giá sẽ có giá trị cột first_review là NULL. Hàm COUNT(first_review) phải bỏ qua các giá trị NULL này.
Lời giải

Cách 1: Đếm dòng và đếm giá trị không khuyết trong SQL ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]

cot_ngay = ["first_review"]


def read_selected(path, columns: list, date_columns: list) -> pd.DataFrame:
    # Đọc tệp có chọn lọc cột và chuyển đổi ngày giờ
    df = pd.read_csv(
        path, usecols=columns, parse_dates=date_columns if date_columns else False
    )
    return df


bang_chon_loc = read_selected("listings_full.csv", cot_can_doc, cot_ngay)

# Bắt đầu lời giải


def sql_count_listings_and_reviews(parquet_path) -> pd.DataFrame:
    tep_posix = Path(parquet_path).as_posix()

    cau_lenh = f"""
    SELECT room_type,
           COUNT(*) AS so_cho_o,
           COUNT(first_review) AS so_co_danh_gia
    FROM '{tep_posix}'
    GROUP BY room_type
    ORDER BY so_cho_o DESC, room_type ASC
    """
    return duckdb.sql(cau_lenh).df()


# Chuẩn bị tệp Parquet mẫu từ bảng chỗ ở
bang_chon_loc.to_parquet("listings.parquet", index=False)
thong_ke_loai_cho = sql_count_listings_and_reviews("listings.parquet")
print("Kết quả thống kê loại chỗ ở và đánh giá:")
print(thong_ke_loai_cho)

# Kết thúc lời giải
Giải thích Cách 1 ​

COUNT(*) đếm tất cả dòng của mỗi loại phòng. COUNT(first_review) chỉ đếm dòng có ô first_review khác NULL. Với fixture này, loại Private room có hai chỗ ở nhưng chỉ một ngày đánh giá đầu tiên đã biết, nên hai số đếm lần lượt là 2 và 1.

Tệp Parquet được tạo từ bảng đã phân tích cột ngày. Ô ngày thiếu thành NaT trong pandas và được lưu thành giá trị null có thể đếm đúng trong DuckDB. Nếu lưu chuỗi rỗng thay cho null, COUNT(cột) có thể đếm nó là một giá trị, vì vậy bước nạp và chuẩn hóa dữ liệu có ảnh hưởng trực tiếp đến kết quả.

GROUP BY room_type tạo một dòng cho mỗi loại. ORDER BY so_cho_o DESC, room_type ASC xếp loại có nhiều chỗ ở trước và dùng tên làm tiêu chí phụ khi hòa. Việc gọi trực tiếp tệp Parquet vẫn có đọc dữ liệu và giải mã theo nhu cầu truy vấn, không có nghĩa mọi khối nén đều được dùng nguyên trạng.

Output Cách 1 ​
room_typeso_cho_oso_co_danh_gia
Entire home/apt44
Private room21

Cách 2: Tạo view để đặt tên nguồn rồi tổng hợp ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL.to_csv("listings_full.csv", index=False)

cot_can_doc = ["id", "room_type", "price", "first_review", "number_of_reviews"]

cot_ngay = ["first_review"]


def read_selected(path, columns: list, date_columns: list) -> pd.DataFrame:
    # Đọc tệp có chọn lọc cột và chuyển đổi ngày giờ
    df = pd.read_csv(
        path, usecols=columns, parse_dates=date_columns if date_columns else False
    )
    return df


bang_chon_loc = read_selected("listings_full.csv", cot_can_doc, cot_ngay)

bang_chon_loc.to_parquet("listings.parquet", index=False)


def sql_count_listings_and_reviews(parquet_path) -> pd.DataFrame:
    tep_posix = Path(parquet_path).as_posix()

    cau_lenh = f"""
    SELECT room_type,
           COUNT(*) AS so_cho_o,
           COUNT(first_review) AS so_co_danh_gia
    FROM '{tep_posix}'
    GROUP BY room_type
    ORDER BY so_cho_o DESC, room_type ASC
    """
    return duckdb.sql(cau_lenh).df()


thong_ke_loai_cho = sql_count_listings_and_reviews("listings.parquet")

# Bắt đầu lời giải


def sql_count_listings_and_reviews_v2(parquet_path) -> pd.DataFrame:
    con = duckdb.connect()
    duong_dan_chuan = Path(parquet_path).resolve().as_posix()

    # Đăng ký khung nhìn tạm thời từ tệp Parquet
    con.execute(
        f"CREATE VIEW v_listings AS SELECT * FROM read_parquet('{duong_dan_chuan}')"
    )

    truy_van = """
    SELECT room_type,
           COUNT(*) AS so_cho_o,
           COUNT(first_review) AS so_co_danh_gia
    FROM v_listings
    GROUP BY room_type
    ORDER BY so_cho_o DESC, room_type ASC
    """
    ket_qua = con.execute(truy_van).df()
    con.close()
    return ket_qua


# Thực thi mẫu phương pháp thứ hai
thong_ke_loai_cho_v2 = sql_count_listings_and_reviews_v2("listings.parquet")
print("Kiểm tra tính trùng khớp:", thong_ke_loai_cho.equals(thong_ke_loai_cho_v2))

# Kết thúc lời giải
Giải thích Cách 2 ​

CREATE VIEW v_listings AS SELECT * FROM read_parquet(...) đặt tên cho một câu truy vấn đọc tệp. View không tự tạo một bản sao dữ liệu được vật chất hóa. Những truy vấn sau có thể dùng tên v_listings để tránh lặp lại đường dẫn và cấu trúc nguồn.

Hai biểu thức COUNT vẫn giữ nguyên ý nghĩa như Cách 1. Sử dụng view giúp tách phần khai báo nguồn với phần phân tích, chứ không tự làm số đếm nhanh hơn. .execute(...).df() chạy truy vấn rồi lấy , sau đó đóng kết nối cục bộ.

Hiệu so_cho_o - so_co_danh_gia cho biết số dòng thiếu ngày đánh giá đầu tiên. Trong fixture được dựng theo quy ước của bài, dòng đó là chỗ ở chưa có đánh giá. Khi dùng dữ liệu ngoài, cần kiểm tra quy ước của cột trước khi suy ra thiếu ngày đồng nghĩa chắc chắn chưa từng có đánh giá.

Output Cách 2 ​
room_typeso_cho_oso_co_danh_gia
Entire home/apt44
Private room21

Kiểm tra giữa hai bảng in True. Tổng toàn mẫu là 6 chỗ ở và 5 ô ngày đánh giá đầu tiên không khuyết.

So sánh hai cách ​

Cách 1 dễ theo dõi cho một truy vấn. Cách 2 hữu ích khi nhiều phép phân tích dùng chung một nguồn. Điểm cần nắm là COUNT(*) đếm dòng, COUNT(cột) đếm ô không null. Chúng có thể bằng nhau hoặc khác nhau tùy đúng cột và dữ liệu đang xét.


Bài 9: Ghép nối bảng DuckDB bằng phép JOIN và phân bổ đánh giá theo loại phòng ​

Mục tiêu: Thực hiện phép ghép nối quan hệ (JOIN) giữa tệp đánh giá và tệp chỗ ở trực tiếp trong DuckDB để phân tích cơ cấu đánh giá theo loại phòng trong một năm cụ thể.

Dữ liệu đầu vào:

  • Đường dẫn tệp đánh giá reviews_path (chứa listing_id và date).
  • Đường dẫn tệp chỗ ở listings_path (chứa id và room_type).
  • Năm phân tích nam (số nguyên, ví dụ 2025).

Thao tác yêu cầu:

  1. Triển khai hàm sql_reviews_by_room_type(reviews_path, listings_path, nam: int) -> pd.DataFrame thực hiện phép INNER JOIN giữa bảng đánh giá và bảng chỗ ở theo điều kiện khóa định danh.
  2. Lọc các đánh giá có year(date) = nam.
  3. Gom nhóm theo room_type, đếm số lượng đánh giá count(*) AS n.
  4. Sắp xếp kết quả giảm dần theo n, trường hợp bằng nhau sắp tăng dần theo room_type.

Trường hợp biên cần chú ý:

  • Ép kiểu tường minh khóa ghép nối giữa hai bảng: Mã chỗ ở có thể được lưu trữ dưới dạng chuỗi hoặc số nguyên lớn. Cần bảo đảm kiểu tương thích khi ghép nối bằng cú pháp CAST(r.listing_id AS BIGINT) = CAST(l.id AS BIGINT).
  • Các đánh giá thuộc về mã chỗ ở không tồn tại trong danh mục chỗ ở (như mã 99 trong dữ liệu mẫu) sẽ tự động bị loại bỏ trong phép ghép trong INNER JOIN.
Lời giải

Cách 1: Ghép từng đánh giá với loại phòng rồi đếm ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL[["id", "name", "room_type"]].to_csv("listings.csv", index=False)

# Bắt đầu lời giải


def sql_reviews_by_room_type(reviews_path, listings_path, nam: int) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()
    l_posix = Path(listings_path).as_posix()

    cau_lenh = f"""
    SELECT l.room_type, count(*) AS n
    FROM '{r_posix}' r
    JOIN '{l_posix}' l ON CAST(r.listing_id AS BIGINT) = CAST(l.id AS BIGINT)
    WHERE year(r.date) = {nam}
    GROUP BY l.room_type
    ORDER BY n DESC, l.room_type ASC
    """
    return duckdb.sql(cau_lenh).df()


# Thực thi mẫu với năm 2025
danh_gia_theo_phong = sql_reviews_by_room_type("reviews.csv", "listings.csv", 2025)
print("Phân bổ đánh giá năm 2025 theo loại phòng:")
print(danh_gia_theo_phong)

# Kết thúc lời giải
Giải thích Cách 1 ​

JOIN trong câu SQL là INNER JOIN. Một đánh giá chỉ đi vào bảng ghép khi r.listing_id tìm được l.id. CAST(... AS BIGINT) đưa hai khóa về cùng kiểu số nguyên trước khi so, với giả thiết các mã đều chuyển được thành số.

WHERE year(r.date) = nam giữ đánh giá thuộc năm cần phân tích. GROUP BY l.room_type gom các đánh giá ghép được theo loại phòng, rồi COUNT(*) đếm số dòng đánh giá trong từng loại. Nếu một chỗ ở có nhiều đánh giá thì các đánh giá đều được đếm, không phải chỉ đếm một chỗ ở.

Trong năm 2025 có 40 đánh giá mẫu. Bốn đánh giá dùng mã 99 không có trong bảng chỗ ở nên bị bỏ bởi phép ghép trong. Kết quả còn 36 đánh giá, với 27 nguyên căn và 9 phòng riêng. Để số đếm không bị nhân lên, cần kiểm tra id duy nhất trong bảng chỗ ở trước khi ghép.

Output Cách 1 ​
room_typenTỷ lệ trong 36 đánh giá ghép được
Entire home/apt2775%
Private room925%

Cách 2: Tách lọc năm và chuẩn hóa khóa thành các CTE ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL[["id", "name", "room_type"]].to_csv("listings.csv", index=False)


def sql_reviews_by_room_type(reviews_path, listings_path, nam: int) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()
    l_posix = Path(listings_path).as_posix()

    cau_lenh = f"""
    SELECT l.room_type, count(*) AS n
    FROM '{r_posix}' r
    JOIN '{l_posix}' l ON CAST(r.listing_id AS BIGINT) = CAST(l.id AS BIGINT)
    WHERE year(r.date) = {nam}
    GROUP BY l.room_type
    ORDER BY n DESC, l.room_type ASC
    """
    return duckdb.sql(cau_lenh).df()


danh_gia_theo_phong = sql_reviews_by_room_type("reviews.csv", "listings.csv", 2025)

# Bắt đầu lời giải


def sql_reviews_by_room_type_v2(reviews_path, listings_path, nam: int) -> pd.DataFrame:
    con = duckdb.connect()
    r_str = Path(reviews_path).resolve().as_posix()
    l_str = Path(listings_path).resolve().as_posix()

    truy_van = f"""
    WITH reviews_nam AS (
        SELECT CAST(listing_id AS BIGINT) AS listing_id
        FROM read_csv_auto('{r_str}')
        WHERE year(date) = {nam}
    ),
    listings_chuan AS (
        SELECT CAST(id AS BIGINT) AS id, room_type
        FROM read_csv_auto('{l_str}')
    )
    SELECT l.room_type, COUNT(*) AS n
    FROM reviews_nam r
    JOIN listings_chuan l ON r.listing_id = l.id
    GROUP BY l.room_type
    ORDER BY n DESC, l.room_type ASC
    """
    ket_qua = con.execute(truy_van).df()
    con.close()
    return ket_qua


# Thực thi mẫu phương pháp thứ hai
danh_gia_v2 = sql_reviews_by_room_type_v2("reviews.csv", "listings.csv", 2025)
print("Kiểm tra tính trùng khớp:", danh_gia_theo_phong.equals(danh_gia_v2))

# Kết thúc lời giải
Giải thích Cách 2 ​

WITH reviews_nam AS (...) đặt tên cho phần đánh giá đã lọc theo năm. listings_chuan chỉ lấy khóa và loại phòng cần dùng. Truy vấn chính sau đó ghép hai nguồn này và tổng hợp giống Cách 1. Các tên trung gian giúp đọc rõ quy trình mà không phải tạo các bảng pandas.

CTE là cách tổ chức câu SQL, chưa phải bằng chứng rằng DuckDB sẽ tạo bảng tạm hay chạy nhanh hơn. Bộ tối ưu có thể đẩy điều kiện lọc trong Cách 1 xuống trước phép ghép. Muốn biết kế hoạch thật, cần dùng EXPLAIN trên dữ liệu và phiên bản đang chạy.

ORDER BY n DESC, l.room_type ASC làm kết quả ổn định khi hai loại có cùng số đếm. Giá trị tỷ lệ trong bảng minh họa dùng mẫu số 36 là tổng số đánh giá ghép được, không dùng 40 và cũng không dùng sáu chỗ ở.

Output Cách 2 ​
room_typenTỷ lệ trong 36 đánh giá ghép được
Entire home/apt2775%
Private room925%

Lệnh so sánh hai cách trả True. Có 4 trong 40 đánh giá năm 2025 không ghép được danh mục chỗ ở.

So sánh hai cách ​

Cách 1 thể hiện trực tiếp quan hệ hai bảng. Cách 2 dễ mở rộng khi cần nhiều bước lọc hoặc chuẩn hóa. Cả hai đang đếm đánh giá, nên tỷ trọng không thể diễn giải thành tỷ lệ đặt phòng, công suất sử dụng hoặc mức hài lòng. Số đánh giá bị loại do thiếu khóa cũng cần được báo cùng kết quả.


Bài 10: Lọc nhóm bằng mệnh đề HAVING trong DuckDB và kiểm chứng bằng pandas ​

Mục tiêu: Phân biệt cơ chế lọc dòng bằng WHERE và cơ chế lọc nhóm sau tổng hợp bằng HAVING trong SQL, đồng thời đối chiếu kiểm chứng chéo kết quả truy vấn với công cụ phân tích pandas.

Dữ liệu đầu vào:

  • Đường dẫn tệp đánh giá reviews_path.
  • Năm lọc dữ liệu nam (ví dụ 2025).
  • Ngưỡng số lượng đánh giá tối thiểu threshold (ví dụ 5).

Thao tác yêu cầu:

  1. Triển khai hàm sql_active_listings_having(reviews_path, nam: int, threshold: int) -> pd.DataFrame sử dụng DuckDB.
  2. Viết câu truy vấn lọc từng dòng theo năm bằng WHERE year(date) = nam, gom nhóm theo listing_id bằng GROUP BY, sau đó lọc các nhóm có số lượng đánh giá thỏa mãn HAVING count(*) >= threshold.
  3. Sắp xếp kết quả giảm dần theo số lượng đánh giá n, nếu bằng nhau sắp tăng dần theo listing_id.
  4. Viết mã kiểm chứng chéo độc lập bằng pandas sử dụng phương thức value_counts().

Trường hợp biên cần chú ý:

  • Mệnh đề WHERE chỉ lọc các bản ghi trước khi thực hiện gom nhóm, do đó không được đưa các hàm tổng hợp như count(*) vào mệnh đề WHERE.
  • Ép kiểu cột listing_id sang số nguyên lớn BIGINT để bảo đảm tính nhất quán định dạng đầu ra.
Lời giải

Cách 1: Lọc dòng bằng WHERE rồi lọc nhóm bằng HAVING ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

# Bắt đầu lời giải


def sql_active_listings_having(reviews_path, nam: int, threshold: int) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()

    cau_lenh = f"""
    SELECT CAST(listing_id AS BIGINT) AS listing_id, count(*) AS n
    FROM '{r_posix}'
    WHERE year(date) = {nam}
    GROUP BY listing_id
    HAVING count(*) >= {threshold}
    ORDER BY n DESC, listing_id ASC
    """
    return duckdb.sql(cau_lenh).df()


# Thực thi mẫu tìm các chỗ ở có từ 5 đánh giá trở lên trong năm 2025
cho_o_tich_cuc = sql_active_listings_having("reviews.csv", 2025, 5)
print("Các chỗ ở có từ 5 đánh giá trở lên:")
print(cho_o_tich_cuc)

# Kiểm chứng chéo độc lập bằng pandas
dem_pandas = DEMO_REVIEWS.loc[
    DEMO_REVIEWS["date"].dt.year == 2025, "listing_id"
].value_counts()
cho_o_pandas = dem_pandas[dem_pandas >= 5].reset_index()
cho_o_pandas.columns = ["listing_id", "n"]
print("Kiểm chứng khớp với pandas:", cho_o_tich_cuc.equals(cho_o_pandas))

# Kết thúc lời giải
Giải thích Cách 1 ​

WHERE year(date) = nam xét từng dòng trước khi tổng hợp. Sau GROUP BY listing_id, mỗi nhóm đại diện cho một mã chỗ ở. HAVING COUNT(*) >= threshold xét số dòng của cả nhóm, nên đúng nơi để yêu cầu ít nhất năm đánh giá.

Không thể đặt COUNT(*) >= 5 vào WHERE của cùng cấp truy vấn vì tại bước lọc dòng chưa có các nhóm để đếm. ORDER BY n DESC, listing_id ASC quy định thứ tự giảm dần theo số đánh giá và tăng dần theo mã khi hòa.

Phần kiểm chứng pandas lấy cột mã của các đánh giá năm 2025, gọi value_counts, lọc số đếm đạt ngưỡng và đổi tên hai cột. Trên fixture, kết quả khớp SQL. Với dữ liệu có các nhóm hòa số đếm, cần sắp cả tiêu chí phụ phía pandas trước khi dùng .equals, vì cùng giá trị nhưng khác thứ tự dòng cũng có thể cho kết quả False.

Output Cách 1 ​
listing_idn
1114
129
135

Kiểm chứng pandas trên fixture trả True. Mã 99 vẫn có thể được tính ở bài này vì chưa ghép với danh mục chỗ ở, nhưng không đạt ngưỡng 5.

Cách 2: Tổng hợp trong truy vấn con rồi lọc cột n ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)


def sql_active_listings_having(reviews_path, nam: int, threshold: int) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()

    cau_lenh = f"""
    SELECT CAST(listing_id AS BIGINT) AS listing_id, count(*) AS n
    FROM '{r_posix}'
    WHERE year(date) = {nam}
    GROUP BY listing_id
    HAVING count(*) >= {threshold}
    ORDER BY n DESC, listing_id ASC
    """
    return duckdb.sql(cau_lenh).df()


cho_o_tich_cuc = sql_active_listings_having("reviews.csv", 2025, 5)

# Bắt đầu lời giải


def sql_active_listings_having_v2(
    reviews_path, nam: int, threshold: int
) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()

    cau_lenh = f"""
    SELECT listing_id, n
    FROM (
        SELECT CAST(listing_id AS BIGINT) AS listing_id, count(*) AS n
        FROM '{r_posix}'
        WHERE year(date) = {nam}
        GROUP BY listing_id
    ) tong_hop
    WHERE n >= {threshold}
    ORDER BY n DESC, listing_id ASC
    """
    return duckdb.sql(cau_lenh).df()


# Thực thi mẫu phương pháp thứ hai
cho_o_tich_cuc_v2 = sql_active_listings_having_v2("reviews.csv", 2025, 5)
print("Kiểm tra tính đồng nhất:", cho_o_tich_cuc.equals(cho_o_tich_cuc_v2))

# Kết thúc lời giải
Giải thích Cách 2 ​

Truy vấn con tính COUNT(*) AS n cho mỗi chỗ ở trong năm. Ở truy vấn bao ngoài, mỗi dòng đầu vào đã là một nhóm được tổng hợp. Vì vậy WHERE n >= threshold của cấp ngoài là hợp lệ và có cùng ý nghĩa lọc nhóm với HAVING ở Cách 1.

Tên tong_hop là bí danh của bảng con, giúp phân biệt hai cấp truy vấn. Đưa điều kiện lọc năm vào cấp trong bảo đảm số đếm chỉ chứa đánh giá của năm cần lấy. Nếu lọc sai cấp hoặc bỏ điều kiện này, số đếm có thể gộp cả nhiều năm.

Hai dạng SQL đều giao việc tổng hợp cho DuckDB. Truy vấn con hữu ích khi điều kiện tiếp theo phải dùng nhiều cột tính được. Nó không tự giảm chi phí tính đếm vì bộ tối ưu có thể chọn kế hoạch tương tự cho cả hai cách.

Output Cách 2 ​
listing_idn
1114
129
135

Bản in Kiểm tra tính đồng nhất giữa hai truy vấn là True.

So sánh hai cách ​

Cách 1 là dạng viết trực tiếp cho điều kiện trên nhóm. Cách 2 giúp nhìn rõ ranh giới giữa bảng đã tổng hợp và dữ liệu gốc. Chọn cách dễ bảo trì cho câu truy vấn đang có. Khi kiểm chứng bằng pandas, phải kiểm tra cả kiểu, thứ tự cột và thứ tự dòng trước khi kết luận hai kết quả khác nhau.


Bài 11: Bài mở rộng 1: Phân tích so sánh chuỗi thời tiết và đánh giá tháng 7 ​

Mục tiêu: Tái lập toàn bộ quy trình thu thập và ghép nối dữ liệu cho một khoảng thời gian đối chứng: Tháng 07 năm 2025 (mùa đông tại bán cầu Nam), đồng thời so sánh mức nhiệt độ và tính ổn định của mối liên hệ với tháng 01.

Dữ liệu đầu vào:

  • Từ điển phản hồi thời tiết giả lập tháng 7 DEMO_WEATHER_JULY (nhiệt độ dao động thấp trong khoảng 11–16°C).
  • Tập dữ liệu đánh giá giả lập tháng 7 DEMO_REVIEWS_JULY. Ngày đánh giá không được coi là ngày đặt phòng hoặc ngày lưu trú.

Thao tác yêu cầu:

  1. Triển khai hàm compare_july_weather_reviews(weather_payload: dict, rv: pd.DataFrame, start: str, end: str) -> dict kết hợp các hàm đã xây dựng ở Bài 4, 5, 6.
  2. Trích xuất bảng thời tiết, đếm đánh giá theo ngày, ghép nối bằng LEFT JOIN và đo lường hệ số tương quan tuyến tính.
  3. So sánh mức nhiệt độ trung bình và số lượng ngày khuyết giữa tháng 1 và tháng 7.

Trường hợp biên cần chú ý:

  • Nếu số lượng ngày quan sát có đánh giá nhỏ hơn 2, phép tính tương quan không thể thực hiện và phải trả về NaN.
Lời giải

Cách 1: Lặp lại quy trình ghép cho mười ngày tháng 7 ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)


def reviews_per_day(rv: pd.DataFrame, start: str, end: str) -> pd.DataFrame:
    # Bản sao an toàn để tránh cảnh báo Copy-on-Write
    du_lieu = rv.copy()
    ngay_bat_dau = pd.to_datetime(start)
    ngay_ket_thuc = pd.to_datetime(end)

    # Bước 1: Lọc các dòng nằm trong khoảng ngày yêu cầu
    dieu_kien = (du_lieu["date"] >= ngay_bat_dau) & (du_lieu["date"] <= ngay_ket_thuc)
    du_lieu_loc = du_lieu.loc[dieu_kien]

    # Bước 2: Gom nhóm theo chuỗi ngày và đếm số lượng
    chuoi_ngay = du_lieu_loc["date"].dt.strftime("%Y-%m-%d")
    dem_ngay = du_lieu_loc.groupby(chuoi_ngay).size().reset_index(name="so_review")

    # Bước 3: Đổi tên cột đồng nhất và sắp xếp thời gian tăng dần
    ket_qua = (
        dem_ngay.rename(columns={"date": "time"})
        .sort_values("time")
        .reset_index(drop=True)
    )
    return ket_qua


thong_ke_ngay = reviews_per_day(DEMO_REVIEWS, "2025-01-01", "2025-01-10")

# Bắt đầu lời giải

# Khởi tạo fixture đối chứng tháng 7
DEMO_WEATHER_JULY = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-07-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            14.2,
            13.5,
            15.0,
            12.8,
            11.4,
            13.9,
            14.7,
            16.1,
            12.0,
            13.3,
        ],
    },
}

DEMO_REVIEWS_JULY = pd.DataFrame(
    {
        "listing_id": [11, 12, 11, 13, 14, 11, 15, 12, 13, 11, 12, 14],
        "date": pd.to_datetime(
            [
                "2025-07-01",
                "2025-07-01",
                "2025-07-02",
                "2025-07-03",
                "2025-07-04",
                "2025-07-04",
                "2025-07-06",
                "2025-07-07",
                "2025-07-08",
                "2025-07-09",
                "2025-07-09",
                "2025-07-10",
            ]
        ),
    }
)


def compare_july_weather_reviews(
    weather_payload: dict, rv: pd.DataFrame, start: str, end: str
) -> dict:
    # 1. Trích xuất bảng thời tiết
    b_thoi_tiet = pd.DataFrame(weather_payload["daily"])

    # 2. Đếm số lượng đánh giá theo ngày
    b_danh_gia = reviews_per_day(rv, start, end)

    # 3. Ghép nối trái
    b_gop = b_thoi_tiet.merge(b_danh_gia, on="time", how="left")
    so_thieu = int(b_gop["so_review"].isna().sum())

    # 4. Tính toán hệ số tương quan
    co_du_lieu = b_gop.dropna(subset=["temperature_2m_max", "so_review"])
    if len(co_du_lieu) > 1:
        he_so = float(co_du_lieu["temperature_2m_max"].corr(co_du_lieu["so_review"]))
    else:
        he_so = float("nan")

    return {
        "bang_thoi_tiet": b_thoi_tiet,
        "bang_danh_gia": b_danh_gia,
        "gop": b_gop,
        "so_ngay_thieu": so_thieu,
        "he_so_tuong_quan": he_so,
    }


# Thực thi mẫu phân tích tháng 7
ket_qua_thang_7 = compare_july_weather_reviews(
    DEMO_WEATHER_JULY, DEMO_REVIEWS_JULY, "2025-07-01", "2025-07-10"
)
print(
    "Nhiệt độ tối đa trung bình tháng 7:",
    round(ket_qua_thang_7["bang_thoi_tiet"]["temperature_2m_max"].mean(), 2),
)
print("Số ngày khuyết đánh giá tháng 7:", ket_qua_thang_7["so_ngay_thieu"])
print("Hệ số tương quan tháng 7:", round(ket_qua_thang_7["he_so_tuong_quan"], 3))

# Kết thúc lời giải
Giải thích Cách 1 ​

pd.DataFrame(weather_payload["daily"]) dựng bảng nhiệt độ theo ngày. reviews_per_day tái sử dụng quy tắc đếm của Bài 5. Ghép trái theo time giữ toàn bộ mười ngày thời tiết và để số đánh giá khuyết khi ngày đó không có trong bảng đếm.

dropna chọn các cặp đủ hai giá trị để tính .corr(). Điều kiện hơn một dòng chỉ kiểm tra số cặp tối thiểu, chưa bảo đảm tương quan xác định nếu một biến là hằng số. Với fixture tháng 7, còn chín cặp và hệ số làm tròn là -0.576.

Nhiệt độ tối đa trung bình 13.69 °C được tính trên toàn bộ mười ngày thời tiết. Số đánh giá có tổng 12, còn hệ số chỉ dùng chín ngày có số đếm. Ba đại lượng không có cùng tập dòng tính toán, vì vậy cần nêu rõ mẫu số và quy tắc khuyết cho từng chỉ số.

Output Cách 1 ​
timeNhiệt độ tối đa (°C)so_review
2025-07-0114.22
2025-07-0213.51
2025-07-03151
2025-07-0412.82
2025-07-0511.4NaN
2025-07-0613.91
2025-07-0714.71
2025-07-0816.11
2025-07-09122
2025-07-1013.31

Kết quả in lần lượt là 13.69, 1 ngày khuyết đánh giá và hệ số -0.576.

Cách 2: Tổng hợp các kết quả hai kỳ vào một bảng so sánh ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

import pandas as pd

DEMO_WEATHER = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-01-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            31.4,
            33.1,
            29.1,
            32.8,
            30.2,
            30.0,
            28.5,
            31.8,
            30.9,
            27.6,
        ],
    },
}

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_WEATHER_JULY = {
    "latitude": -33.45,
    "longitude": -70.66,
    "timezone": "America/Santiago",
    "daily_units": {"time": "iso8601", "temperature_2m_max": "°C"},
    "daily": {
        "time": [f"2025-07-{d:02d}" for d in range(1, 11)],
        "temperature_2m_max": [
            14.2,
            13.5,
            15.0,
            12.8,
            11.4,
            13.9,
            14.7,
            16.1,
            12.0,
            13.3,
        ],
    },
}

DEMO_REVIEWS_JULY = pd.DataFrame(
    {
        "listing_id": [11, 12, 11, 13, 14, 11, 15, 12, 13, 11, 12, 14],
        "date": pd.to_datetime(
            [
                "2025-07-01",
                "2025-07-01",
                "2025-07-02",
                "2025-07-03",
                "2025-07-04",
                "2025-07-04",
                "2025-07-06",
                "2025-07-07",
                "2025-07-08",
                "2025-07-09",
                "2025-07-09",
                "2025-07-10",
            ]
        ),
    }
)

# Bắt đầu lời giải


def analyze_weather_period(payload, reviews, start, end):
    weather = pd.DataFrame(payload["daily"])
    selected = reviews.loc[reviews["date"].between(start, end)]
    per_day = (
        selected.groupby(selected["date"].dt.strftime("%Y-%m-%d"))
        .size()
        .rename("so_review")
        .rename_axis("time")
        .reset_index()
    )
    joined = weather.merge(per_day, on="time", how="left", validate="one_to_one")
    return {
        "gop": joined,
        "so_thieu": int(joined["so_review"].isna().sum()),
        "he_so": float(joined["temperature_2m_max"].corr(joined["so_review"])),
    }


def summarize_seasonal_comparison(kq_thang1: dict, kq_thang7: dict) -> pd.DataFrame:
    chi_so = [
        "Nhiệt độ tối đa trung bình (°C)",
        "Tổng số lượt đánh giá quan sát",
        "Số ngày khuyết thiếu đánh giá",
        "Hệ số tương quan Pearson",
    ]
    gt_thang1 = [
        round(kq_thang1["gop"]["temperature_2m_max"].mean(), 2),
        int(kq_thang1["gop"]["so_review"].sum()),
        int(kq_thang1["so_thieu"]),
        round(kq_thang1["he_so"], 3),
    ]
    gt_thang7 = [
        round(kq_thang7["gop"]["temperature_2m_max"].mean(), 2),
        int(kq_thang7["gop"]["so_review"].sum()),
        int(kq_thang7["so_thieu"]),
        round(kq_thang7["he_so"], 3),
    ]

    return pd.DataFrame(
        {
            "Chỉ số đối chiếu": chi_so,
            "Tháng 01/2025": gt_thang1,
            "Tháng 07/2025": gt_thang7,
        }
    )


# Thực thi tổng hợp so sánh
thang_mot = analyze_weather_period(
    DEMO_WEATHER, DEMO_REVIEWS, "2025-01-01", "2025-01-10"
)
thang_bay = analyze_weather_period(
    DEMO_WEATHER_JULY, DEMO_REVIEWS_JULY, "2025-07-01", "2025-07-10"
)
bang_so_sanh = summarize_seasonal_comparison(thang_mot, thang_bay)
print("Bảng so sánh hai khoảng dữ liệu giả lập:")
print(bang_so_sanh)

# Kết thúc lời giải
Giải thích Cách 2 ​

Hàm summarize_seasonal_comparison nhận kết quả của hai quy trình đã chạy, lấy cùng bốn chỉ số rồi đặt cạnh nhau. Các khóa so_thieu, he_so của tháng 1 và so_ngay_thieu, he_so_tuong_quan của tháng 7 khác tên, nên cần đọc đúng từng từ điển.

.mean() lấy trung bình nhiệt độ tối đa trong kỳ. .sum() cộng số đánh giá đã ghép, còn số ngày khuyết được lấy từ kết quả kiểm tra. round chỉ làm tròn bảng báo cáo. Nó không thay đổi dữ liệu nguồn dùng tính hệ số.

Các nhãn tháng trong bảng giúp phân biệt hai fixture. Hai kỳ chỉ gồm mười ngày mỗi tháng, nên không thể lấy chênh lệch này làm kết luận về cả mùa, khí hậu hoặc hành vi khách du lịch. Hệ số khác nhau cũng không chứng minh khác biệt về quan hệ nhân quả.

Output Cách 2 ​
Chỉ số01–10/01/202501–10/07/2025
Trung bình nhiệt độ tối đa (°C)30.5413.69
Tổng số đánh giá đã quan sát3612
Ngày không ghép được số đếm11
Pearson, làm tròn 3 chữ số0.084-0.576

So sánh hai cách ​

Cách 1 thực hiện phân tích kỳ tháng 7 từ dữ liệu đầu vào. Cách 2 là bước trình bày đối chiếu từ các kết quả đã tính, không phải một thuật toán khác thay thế toàn bộ quy trình. Cả hai đều hữu ích khi học cách chia việc thành hàm, nhưng cần giữ rõ phạm vi mười ngày và dữ liệu giả lập khi diễn giải.


Bài 12: Bài mở rộng 2: Truy vấn top chỗ ở nhận nhiều đánh giá nhất kèm tên định danh ​

Mục tiêu: Xây dựng truy vấn phân tích thứ hạng trong DuckDB kết hợp thông tin định danh từ hai bảng quan hệ khác nhau, đồng thời kiểm chứng chéo thứ tự xếp hạng bằng công cụ pandas.

Dữ liệu đầu vào:

  • Đường dẫn tệp đánh giá reviews_path.
  • Đường dẫn tệp chỗ ở listings_path chứa cột name mô tả tên phòng.
  • Năm lọc dữ liệu nam (ví dụ 2025).
  • Số lượng bản ghi giới hạn limit (mặc định lấy 5 chỗ ở đứng đầu).

Thao tác yêu cầu:

  1. Triển khai hàm sql_top_reviewed_listings(reviews_path, listings_path, nam: int, limit: int = 5) -> pd.DataFrame sử dụng DuckDB.
  2. Thực hiện ghép INNER JOIN giữa bảng đánh giá và bảng chỗ ở.
  3. Gom nhóm theo cả hai trường listing_id và tên phòng name.
  4. Đếm số lượng đánh giá, sắp xếp giảm dần theo số đánh giá n, nếu bằng nhau sắp tăng dần theo listing_id, và giới hạn đầu ra bằng mệnh đề LIMIT.

Trường hợp biên cần chú ý:

  • Chú ý tính tương thích kiểu dữ liệu khi ghép khóa: listing_id có thể là chuỗi trong khi id là số nguyên.
  • Đảm bảo việc gom nhóm bao gồm cả cột tên phòng name để câu lệnh SQL tuân thủ đúng chuẩn ANSI SQL.
Lời giải

Cách 1: Ghép tên chỗ ở và lấy các nhóm đứng đầu bằng SQL ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL[["id", "name", "room_type"]].to_csv("listings.csv", index=False)

# Bắt đầu lời giải


def sql_top_reviewed_listings(
    reviews_path, listings_path, nam: int, limit: int = 5
) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()
    l_posix = Path(listings_path).as_posix()

    cau_lenh = f"""
    SELECT CAST(r.listing_id AS BIGINT) AS listing_id,
           l.name,
           count(*) AS n
    FROM '{r_posix}' r
    JOIN '{l_posix}' l ON CAST(r.listing_id AS BIGINT) = CAST(l.id AS BIGINT)
    WHERE year(r.date) = {nam}
    GROUP BY r.listing_id, l.name
    ORDER BY n DESC, listing_id ASC
    LIMIT {limit}
    """
    return duckdb.sql(cau_lenh).df()


# Thực thi mẫu tìm top 5 chỗ ở năm 2025
top_5_cho_o = sql_top_reviewed_listings("reviews.csv", "listings.csv", 2025, 5)
print("Top 5 chỗ ở nhiều đánh giá nhất năm 2025:")
print(top_5_cho_o)

# Kết thúc lời giải
Giải thích Cách 1 ​

JOIN lấy tên ứng với mỗi mã chỗ ở trong các đánh giá năm cần phân tích. GROUP BY r.listing_id, l.name tạo nhóm cho từng cặp mã và tên để cả hai trường xuất hiện hợp lệ trong SELECT. Giả thiết quan trọng là mỗi id trong danh mục chỉ có một dòng tương ứng.

COUNT(*) AS n đếm đánh giá ghép được, rồi ORDER BY n DESC, listing_id ASC quy định cả thứ tự chính lẫn phụ. Hai chỗ ở 14 và 15 có cùng bốn đánh giá, nên mã 14 đứng trước. LIMIT 5 chỉ lấy năm dòng đầu của kết quả đã xếp.

LIMIT không có nghĩa DuckDB chỉ đọc năm dòng tệp hay chỉ giữ năm nhóm trong mọi giai đoạn. Hệ quản trị vẫn phải xác định các số đếm để biết nhóm nào đứng đầu. Mã 99 không ghép được nên không thể xuất hiện trong bảng có tên chỗ ở.

Output Cách 1 ​
listing_idnamen
11Depto Lastarria14
12Pieza Bellavista9
13Loft Providencia5
14Casa Ñuñoa4
15Studio Centro4

Cách 2: Đếm trước trong pandas rồi ghép tên và xếp hạng ​

python
# Dữ liệu mẫu và các hàm hỗ trợ

from pathlib import Path

import duckdb

import pandas as pd

_danh_sach_ngay = []

for ngay, so_luong in zip(
    pd.date_range("2025-01-01", "2025-01-10"), [3, 5, 2, 6, 0, 4, 7, 1, 5, 3]
):
    _danh_sach_ngay += [ngay] * so_luong

_danh_sach_ngay += list(
    pd.to_datetime(
        [
            "2023-03-15",
            "2023-08-02",
            "2023-11-20",
            "2024-02-11",
            "2024-05-05",
            "2024-07-19",
            "2024-09-30",
            "2024-12-24",
            "2024-12-31",
            "2025-03-08",
            "2025-06-21",
            "2025-09-18",
            "2025-12-05",
            "2026-02-14",
            "2026-04-01",
        ]
    )
)

DEMO_REVIEWS = pd.DataFrame(
    {
        "listing_id": [
            [11, 12, 11, 13, 14, 11, 15, 12, 99][k % 9]
            for k in range(len(_danh_sach_ngay))
        ],
        "date": _danh_sach_ngay,
    }
)

DEMO_REVIEWS.to_csv("reviews.csv", index=False)

DEMO_LISTINGS_FULL = pd.DataFrame(
    {
        "id": [11, 12, 13, 14, 15, 16],
        "name": [
            "Depto Lastarria",
            "Pieza Bellavista",
            "Loft Providencia",
            "Casa Ñuñoa",
            "Studio Centro",
            "Depto nuevo",
        ],
        "host_id": [501, 502, 501, 503, 504, 505],
        "neighbourhood_cleansed": [
            "Santiago",
            "Recoleta",
            "Providencia",
            "Ñuñoa",
            "Santiago",
            "Las Condes",
        ],
        "room_type": [
            "Entire home/apt",
            "Private room",
            "Entire home/apt",
            "Entire home/apt",
            "Entire home/apt",
            "Private room",
        ],
        "price": [
            "$45,647.00",
            "$18,500.00",
            "$1,120,000.00",
            None,
            "$59,000.00",
            "$25,990.50",
        ],
        "first_review": [
            "2023-03-15",
            "2023-08-02",
            "2024-02-11",
            "2023-11-20",
            "2025-01-02",
            None,
        ],
        "number_of_reviews": [14, 9, 8, 4, 6, 0],
        "review_scores_rating": [4.91, 4.75, 4.88, 5.0, 4.6, None],
    }
)

DEMO_LISTINGS_FULL[["id", "name", "room_type"]].to_csv("listings.csv", index=False)


def sql_top_reviewed_listings(
    reviews_path, listings_path, nam: int, limit: int = 5
) -> pd.DataFrame:
    r_posix = Path(reviews_path).as_posix()
    l_posix = Path(listings_path).as_posix()

    cau_lenh = f"""
    SELECT CAST(r.listing_id AS BIGINT) AS listing_id,
           l.name,
           count(*) AS n
    FROM '{r_posix}' r
    JOIN '{l_posix}' l ON CAST(r.listing_id AS BIGINT) = CAST(l.id AS BIGINT)
    WHERE year(r.date) = {nam}
    GROUP BY r.listing_id, l.name
    ORDER BY n DESC, listing_id ASC
    LIMIT {limit}
    """
    return duckdb.sql(cau_lenh).df()


top_5_cho_o = sql_top_reviewed_listings("reviews.csv", "listings.csv", 2025, 5)

# Bắt đầu lời giải


def top_reviewed_pandas(
    rv: pd.DataFrame, ls: pd.DataFrame, nam: int, limit: int = 5
) -> pd.DataFrame:
    # 1. Lọc đánh giá theo năm và đếm tần suất
    rv_nam = rv.loc[rv["date"].dt.year == nam].copy()
    dem = rv_nam["listing_id"].value_counts().reset_index()
    dem.columns = ["listing_id", "n"]

    # 2. Ghép nối lấy thông tin tên phòng
    ls_sub = ls[["id", "name"]].rename(columns={"id": "listing_id"})
    gop = dem.merge(ls_sub, on="listing_id", how="inner")

    # 3. Sắp xếp và lấy top đầu
    ket_qua = (
        gop[["listing_id", "name", "n"]]
        .sort_values(by=["n", "listing_id"], ascending=[False, True])
        .head(limit)
        .reset_index(drop=True)
    )

    return ket_qua


# Thực thi kiểm chứng chéo bằng pandas
top_5_pandas = top_reviewed_pandas(DEMO_REVIEWS, DEMO_LISTINGS_FULL, 2025, 5)
print("Kiểm tra khớp giữa DuckDB và pandas:", top_5_cho_o.equals(top_5_pandas))

# Kết thúc lời giải
Giải thích Cách 2 ​

rv["date"].dt.year == nam tạo điều kiện chọn năm. value_counts thu cột mã thành bảng số đếm trước khi ghép tên, nên bảng tham gia ghép phía đánh giá chỉ còn một dòng cho mỗi mã. Trên dữ liệu có nhiều đánh giá cho một chỗ ở, đây là một cách giảm kích thước bảng trung gian.

ls[["id", "name"]] chỉ lấy hai cột cần tra cứu. rename đưa tên khóa id về listing_id, rồi merge(..., how="inner") giữ các mã tìm được tên. sort_values nhận hai cột và hai chiều sắp xếp, bảo đảm giữ cùng quy tắc hòa với SQL. .head(limit) lấy giới hạn và reset_index(drop=True) đặt lại chỉ mục cho bảng kết quả.

Hàm dùng sắp xếp nhiều tiêu chí, không dùng nlargest. Đó là điều cần thiết để thể hiện rõ thứ tự mã khi số đếm bằng nhau. Khi đối chiếu với DuckDB, cùng kiểu dữ liệu và cùng thứ tự cột giúp .equals kiểm tra được toàn bộ bảng.

Output Cách 2 ​
listing_idnamen
11Depto Lastarria14
12Pieza Bellavista9
13Loft Providencia5
14Casa Ñuñoa4
15Studio Centro4

Kiểm tra giữa DuckDB và pandas trên fixture trả True.

So sánh hai cách ​

Cách 1 phù hợp khi muốn truy vấn trực tiếp các tệp và chỉ nhận bảng tổng hợp về pandas. Cách 2 mở rõ các bước đếm, ghép và xếp hạng, thuận tiện nếu đã có sẵn trong bộ nhớ. Cả hai đòi hỏi quy tắc khóa và tiêu chí hòa giống nhau. Bảng xếp hạng số đánh giá không đồng nghĩa bảng xếp hạng số lượt đặt hoặc chất lượng chỗ ở.

Tài liệu tham khảo ​

Ảnh & sơ đồ
100%
Ảnh tài liệu được phóng to