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

Trực quan hóa dữ liệu và đọc biểu đồ

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

Một bảng dữ liệu chứa hàng chục nghìn con số có thể ẩn giấu những tri thức vô giá, nhưng bộ não con người không được tiến hóa để đọc hiểu các ma trận số học khô khốc một cách trực quan. Trực quan hóa dữ liệu không đơn thuần là việc vẽ những bức tranh minh họa bắt mắt. Về bản chất khoa học, đây là quá trình mã hóa thị giác thông tin định lượng (Visual Encoding of Quantitative Information), biến các giá trị toán học trừu tượng thành các thuộc tính không gian và hình học mà mắt người có thể tiếp nhận và phân tích tức thì.

Các nghiên cứu kinh điển về nhận thức thị giác (tiêu biểu là công trình của William Cleveland và Robert McGill năm 1984) đã chứng minh rằng: Mắt người phán đoán vị trí trên cùng một thang đo và so sánh chiều dài thanh với độ chính xác cao nhất. Trái lại, việc ước lượng diện tích, góc nghiêng hay độ đậm nhạt của màu sắc thường có sai số nhận thức lớn hơn rất nhiều. Do đó, việc lựa chọn dạng biểu đồ nào hoàn toàn không phụ thuộc vào cảm tính thẩm mỹ cá nhân, mà được quyết định bởi bản chất cấu trúc của dữ liệu và thông điệp phân tích bạn muốn truyền tải.

Bài học này cung cấp nền tảng bài bản về trực quan hóa dữ liệu trong Python bằng kiến trúc hướng đối tượng của Matplotlib. Người học sẽ thực hành vẽ các biểu đồ thống kê với pandas và Seaborn, tìm hiểu cơ chế ước lượng mật độ, và rèn luyện kỹ năng phản biện biểu đồ nhằm bảo đảm tính trung thực của các ấn phẩm phân tích.


1. Sơ lược về giao diện API của Matplotlib ​

Thư viện Matplotlib là nền móng của toàn bộ hệ sinh thái trực quan hóa dữ liệu trong Python. Mặc dù Matplotlib cung cấp giao diện toàn cục theo phong cách MATLAB (plt.plot), trong môi trường kỹ nghệ dữ liệu chuyên nghiệp và các đường ống tự động hóa, chuẩn mực bắt buộc là sử dụng giao diện hướng đối tượng (Object-Oriented API) tường minh.

1.1. Kiến trúc Figure và Subplots ​

Kiến trúc hướng đối tượng phân tách rạch ròi hai thực thể nền tảng:

  • Figure: Đại diện cho toàn bộ khung tranh vật lý, bao gồm kích thước khung hình (tính bằng inch figsize), độ phân giải xuất bản (dpi), màu nền và toàn bộ các biểu đồ con bên trong.
  • Axes: Đại diện cho một hệ tọa độ cụ thể nằm trên Figure. Một đối tượng Axes sở hữu trục hoành (X), trục tung (Y), lưới tọa độ, vạch chia (ticks), nhãn và toàn bộ các đối tượng hình học (đường nét, cột, điểm phân tán). Một Figure có thể chứa một hoặc nhiều Axes.

Hàm plt.subplots() là điểm khởi đầu chuẩn mực để khởi tạo đồng thời cả Figure và lưới các Axes:

python
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd

# Khởi tạo Figure và lưới 2x2 gồm 4 Axes
fig, axes = plt.subplots(2, 2, figsize=(8, 6), sharex=False, sharey=False)

# Vẽ dữ liệu lên từng hệ tọa độ độc lập
axes[0, 0].hist(np.random.standard_normal(200), bins=20, color="#1E93AB", alpha=0.6)
axes[0, 0].set_title("Histogram biến chuẩn")

axes[0, 1].scatter(np.arange(30), np.arange(30) + 3 * np.random.standard_normal(30), color="#D65A31")
axes[0, 1].set_title("Biểu đồ phân tán")

axes[1, 0].plot(np.random.standard_normal(50).cumsum(), color="#393E46", linestyle="--")
axes[1, 0].set_title("Đường ngẫu nhiên")

axes[1, 1].bar(["Nhóm A", "Nhóm B", "Nhóm C"], [15, 28, 19], color="#2E7D32")
axes[1, 1].set_title("So sánh độ lớn")

fig.tight_layout()
plt.close(fig)

1.2. Căn chỉnh khoảng cách với subplots_adjust và tight_layout ​

Mặc định, Matplotlib để lại các khoảng trống nhất định xung quanh các biểu đồ con. Phương thức fig.tight_layout() tự động căn chỉnh lề để các nhãn trục không bị đè lên nhau. Khi cần tinh chỉnh thủ công khoảng cách giữa các khung vẽ, ta sử dụng:

python
fig.subplots_adjust(left=0.1, right=0.95, bottom=0.1, top=0.9, wspace=0.3, hspace=0.3)

Trong đó, wspace và hspace kiểm soát tỷ lệ khoảng cách theo chiều ngang và chiều dọc giữa các Axes.

1.3. Màu sắc, điểm đánh dấu và kiểu đường nét ​

Khi vẽ biểu đồ đường hoặc đồ thị điểm, các tham số định dạng thẩm mỹ bao gồm:

  • color: Tên màu chuẩn ("red", "teal"), chữ cái viết tắt ("k" cho màu đen, "b" cho màu xanh dương), hoặc mã màu Hex ("#1E93AB").
  • linestyle: Kiểu nét vẽ ("-" nét liền, "--" nét đứt, ":" nét chấm, "-." nét gạch chấm).
  • marker: Ký hiệu điểm đánh dấu ("o" hình tròn, "s" hình vuông, "^" hình tam giác, "x" dấu nhân).
  • alpha: Độ trong suốt từ 0,0 (trong suốt hoàn toàn) đến 1,0 (đặc hoàn toàn), vũ khí chủ lực để xử lý hiện tượng chồng lấn điểm (Overplotting).

1.4. Vạch chia, nhãn và chú giải (Ticks, Labels, and Legends) ​

Để một biểu đồ có thể tự đứng được mà không cần người thuyết minh, mọi trục tọa độ đều phải được ghi nhãn rõ ràng kèm đơn vị đo lường:

  • ax.set_xticks() và ax.set_xticklabels(): Định vị các mốc vạch chia và gán nhãn văn bản (hỗ trợ tham số rotation=30 khi nhãn quá dài).
  • ax.set_xlabel(), ax.set_ylabel(): Tên đại lượng và đơn vị đo lường.
  • ax.set_title(): Tiêu đề nêu bật thông điệp phân tích của biểu đồ.
  • ax.legend(loc="best"): Thêm hộp chú giải giải thích ý nghĩa của từng đường vẽ dựa trên tham số label đã gán.

1.5. Chú thích và vẽ hình học trên Subplot ​

Bên cạnh các đường vẽ chính, việc dẫn dắt ánh nhìn của người đọc đến các sự kiện đặc biệt (như mốc khủng hoảng kinh tế hoặc điểm bão hòa) được thực hiện bằng chú thích:

  • ax.text(x, y, chuoi): Vẽ chuỗi văn bản tại tọa độ (x,y).
  • ax.annotate(): Vẽ đoạn văn bản kèm theo mũi tên chỉ trực tiếp vào điểm dữ liệu quan trọng:
python
fig, ax = plt.subplots(figsize=(6, 3.5))
ax.plot([1, 2, 3, 4, 5], [10, 14, 25, 18, 12], marker="o", color="#1E93AB")

# Chỉ mũi tên vào đỉnh cực đại:
ax.annotate(
    "Đỉnh doanh thu",
    xy=(3, 25),
    xytext=(3.5, 23),
    arrowprops=dict(facecolor="black", shrink=0.08, width=1.5, headwidth=6)
)
ax.set_ylim(bottom=0)
plt.close(fig)

Ngoài ra, ta có thể bổ sung các hình học trực quan như đường thẳng tham chiếu ngang/dọc (ax.axhline(), ax.axvline()) hoặc các mảng hình học từ matplotlib.patches (như Rectangle, Circle) thông qua phương thức ax.add_patch().

1.6. Xuất bản biểu đồ ra tệp với savefig ​

Phương thức fig.savefig() cho phép lưu biểu đồ xuống đĩa cứng với hai họ định dạng chính:

  • Định dạng điểm ảnh (Raster Image - PNG, JPEG): Biểu diễn hình ảnh bằng lưới các pixel. Thích hợp cho các biểu đồ chứa hàng triệu điểm dữ liệu phân tán. Cần thiết lập dpi=300 để bảo đảm độ sắc nét khi in ấn.
  • Định dạng vector (Vector Image - SVG, PDF): Biểu diễn hình ảnh bằng các phương trình toán học về đường cong và nét vẽ. Cho phép phóng to thu nhỏ tùy ý mà không bao giờ bị vỡ hạt, lý tưởng cho các bài báo khoa học và tài liệu web.
  • Tham số bbox_inches="tight": Tự động cắt bỏ các khoảng trắng thừa ở viền ngoài bức tranh.

1.7. Cấu hình giao diện toàn cục với rcParams ​

Để toàn bộ các biểu đồ trong dự án tuân theo một ngôn ngữ thiết kế đồng nhất, Matplotlib cho phép cấu hình các tham số hệ thống thông qua plt.rcParams.update():

python
plt.rcParams.update({
    "font.size": 10,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "axes.grid": True,
    "grid.alpha": 0.3,
    "figure.autolayout": True
})

2. Vẽ biểu đồ với pandas và Seaborn ​

Trong khi Matplotlib đóng vai trò là động cơ vẽ cấp thấp, pandas và Seaborn cung cấp các giao diện cấp cao được thiết kế riêng để tương tác trực tiếp với cấu trúc .

2.1. Vẽ nhanh với pandas .plot ​

Các đối tượng Series và tích hợp sẵn thuộc tính .plot:

  • Biểu đồ đường: Dùng df.plot(kind="line") hoặc df.plot().
  • Biểu đồ cột: Dùng s.plot.bar() (cột đứng) hoặc s.plot.barh() (thanh ngang). Tham số stacked=True cho phép vẽ biểu đồ cột chồng tích lũy.
python
df_doanh_so = pd.DataFrame({
    "Chi_nhanh_A": [120, 150, 180],
    "Chi_nhanh_B": [90, 140, 160]
}, index=["Quý 1", "Quý 2", "Quý 3"])

ax = df_doanh_so.plot.bar(figsize=(6, 4), color=["#1E93AB", "#D65A31"])
ax.set_ylabel("Doanh thu (triệu đồng)")
plt.close(ax.get_figure())

2.2. Biểu đồ tần suất và ước lượng mật độ nhân (KDE) ​

  • Histogram: Dùng s.plot.hist(bins=20) chia dữ liệu thành các khoảng và đếm tần số xuất hiện.
  • Biểu đồ mật độ (Density Plot / KDE): Dùng s.plot.kde() hoặc sns.kdeplot() làm mịn phân phối dữ liệu rời rạc thành một đường cong liên tục bằng phương pháp Ước lượng Mật độ Nhân (Kernel Density Estimation). Mỗi quan sát thực tế được mô hình hóa bằng một hàm nhân chuẩn hóa thu nhỏ (thường là phân phối Gauss), và đường cong mật độ tổng thể là tổng tích lũy của toàn bộ các hàm nhân này.

Tham số độ rộng băng thông (bandwidth, điều khiển bằng bw_adjust trong Seaborn) quyết định mức độ làm mịn của đường cong. Băng thông quá hẹp sẽ khiến đường cong gồ ghề và bắt chước nhiễu ngẫu nhiên, trong khi băng thông quá rộng sẽ làm lu mờ các đỉnh phân phối quan trọng:

python
import seaborn as sns

du_lieu_mau = np.array([1.2, 1.5, 1.8, 3.8, 4.0, 4.2, 4.5, 4.8])
fig, ax = plt.subplots(figsize=(6, 3.5))

# So sánh băng thông chuẩn và băng thông nhạy dao động
sns.kdeplot(du_lieu_mau, bw_adjust=1.0, label="Băng thông chuẩn (bw_adjust=1.0)", color="#1E93AB", ax=ax)
sns.kdeplot(du_lieu_mau, bw_adjust=0.5, label="Băng thông hẹp (bw_adjust=0.5)", color="#D65A31", linestyle="--", ax=ax)
ax.set_title("Ước lượng mật độ nhân với các mức băng thông khác nhau")
ax.legend()
plt.close(fig)

2.3. Biểu đồ phân tán, đồ thị hồi quy và ma trận tương quan ​

  • sns.scatterplot(x="bien_x", y="bien_y", hue="nhom", data=df): Thể hiện mối quan hệ giữa hai biến số thực, hỗ trợ phân biệt các nhóm đối tượng bằng kênh màu sắc hue.
  • sns.regplot(): Tự động ước lượng và vẽ đường hồi quy tuyến tính đi kèm dải tin cậy 95% (Confidence Interval Band), giúp đánh giá trực quan hướng vận động và độ chặt chẽ của mối liên hệ giữa hai biến số.
  • sns.boxplot(): Biểu diễn phân phối định lượng qua các tứ phân vị và nhận diện giá trị ngoại lai, kết hợp tham số hue để phân rã thêm một chiều phân loại phụ.
python
df_tips = pd.DataFrame({
    "ngay": ["Thứ 5", "Thứ 5", "Thứ 6", "Thứ 6", "Thứ 7", "Thứ 7", "CN", "CN"],
    "tong_hoa_don": [18.5, 25.0, 22.0, 30.5, 35.0, 42.0, 38.0, 48.5],
    "thoi_gian": ["Trưa", "Tối", "Trưa", "Tối", "Tối", "Tối", "Trưa", "Tối"]
})

fig, ax = plt.subplots(figsize=(6, 4))
sns.boxplot(data=df_tips, x="ngay", y="tong_hoa_don", hue="thoi_gian", palette="Set2", ax=ax)
ax.set_title("Phân bố hóa đơn theo ngày và thời gian với kênh màu sắc hue")
ax.set_ylabel("Tổng hóa đơn (USD)")
plt.close(fig)
  • sns.heatmap(): Trực quan hóa ma trận số học hoặc ma trận hệ số tương quan bằng bản đồ nhiệt màu, hiển thị trực tiếp giá trị số trên từng ô qua tham số annot=True:
python
df_tuong_quan = pd.DataFrame({
    "Doanh_thu": [100, 150, 200, 250, 300],
    "Chi_phi_QC": [10, 18, 25, 32, 40],
    "So_don_hang": [50, 70, 95, 110, 130]
})
ma_tran_corr = df_tuong_quan.corr()

fig, ax = plt.subplots(figsize=(5, 4))
sns.heatmap(ma_tran_corr, annot=True, cmap="Blues", fmt=".2f", vmin=-1, vmax=1, ax=ax)
ax.set_title("Ma trận tương quan với Seaborn Heatmap")
plt.close(fig)

2.4. Lưới biểu đồ khía cạnh với FacetGrid và catplot ​

Khi tập dữ liệu chứa nhiều chiều phân loại, việc nhồi nhét tất cả vào một biểu đồ duy nhất sẽ gây quá tải thị giác (Visual Clutter). Seaborn giải quyết bài toán này bằng kỹ thuật lưới biểu đồ khía cạnh:

  • sns.catplot(x=..., y=..., hue=..., data=..., kind="box"|"violin"|"bar"): Trực quan hóa dữ liệu phân loại với các kiểu hiển thị đa dạng như biểu đồ hộp (Boxplot) hay biểu đồ vĩ cầm (Violin plot).
  • sns.FacetGrid: Tạo một ma trận gồm nhiều biểu đồ nhỏ (Small Multiples), trong đó mỗi biểu đồ con khảo sát cùng một mối quan hệ nhưng trên một phân khúc dữ liệu riêng biệt:
python
df_khao_sat = pd.DataFrame({
    "chi_phi": [15, 22, 40, 18, 35, 50, 12, 28],
    "tien_tip": [2.5, 3.0, 6.0, 2.8, 5.0, 8.5, 1.8, 4.2],
    "buoi": ["Trưa", "Tối", "Tối", "Trưa", "Tối", "Tối", "Trưa", "Tối"],
    "gioi_tinh": ["Nam", "Nữ", "Nam", "Nữ", "Nam", "Nữ", "Nam", "Nữ"]
})

g = sns.FacetGrid(df_khao_sat, col="buoi", row="gioi_tinh", margin_titles=True)
g.map(sns.scatterplot, "chi_phi", "tien_tip", color="#1E93AB")
plt.close(g.fig)

3. Phản biện biểu đồ và nguyên tắc trung thực thị giác ​

Trực quan hóa dữ liệu có vai trò định hướng nhận thức quan trọng. Một biểu đồ được thiết kế sai lệch có thể phóng đại những chênh lệch nhỏ, hoặc ngược lại che giấu đi những biến động đáng lưu tâm trong dữ liệu.

3.1. Phản biện ba cạm bẫy trực quan hóa kinh điển ​

Cạm bẫy 1: Cắt cụt trục tung của biểu đồ cột (Truncated Y-axis) ​

  • Cơ chế: Khi nhìn vào biểu đồ cột hoặc thanh ngang, mắt người tự động mã hóa độ lớn dựa trên chiều dài hình học của thanh so với đường cơ sở (baseline).
  • Hệ quả: Nếu tỷ lệ của hai đơn vị là 94% và 98%, nhưng người vẽ cắt cụt trục tung bắt đầu từ mốc 90%: Thanh thứ hai sẽ có chiều dài hiển thị là 98−90=8 đơn vị, gấp đôi thanh thứ nhất (94−90=4 đơn vị). Người đọc dễ lầm tưởng rằng đơn vị thứ hai vượt trội gấp đôi, trong khi khoảng cách thực tế chỉ là 4 điểm phần trăm.
  • Nguyên tắc trực quan: Đối với biểu đồ cột và thanh ngang, trục đo chiều dài bắt buộc phải xuất phát từ mốc 0 để bảo toàn tỷ lệ trực quan:
python
df_so_sanh = pd.DataFrame({
    "don_vi": ["Đơn vị A", "Đơn vị B"],
    "ty_le": [94.0, 98.0]
})

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(8, 3.5))

# Biểu đồ sai lệch: Trục tung bị cắt cụt từ 90 làm méo mó trực quan
ax1.bar(df_so_sanh["don_vi"], df_so_sanh["ty_le"], color=["#1E93AB", "#D65A31"])
ax1.set_ylim(bottom=90, top=100)
ax1.set_title("Trục tung cắt cụt (Phóng đại chênh lệch)")
ax1.set_ylabel("Tỷ lệ (%)")

# Biểu đồ chuẩn mực: Trục tung bắt đầu từ 0 bảo toàn tỷ lệ chiều dài
ax2.bar(df_so_sanh["don_vi"], df_so_sanh["ty_le"], color=["#1E93AB", "#D65A31"])
ax2.set_ylim(bottom=0, top=100)
ax2.set_title("Trục tung từ 0 (Trung thực)")
ax2.set_ylabel("Tỷ lệ (%)")

fig.tight_layout()
plt.close(fig)

Cạm bẫy 2: Cạm bẫy trục tung kép (Dual Y-axis) ​

  • Cơ chế: Đặt hai biến số có thang đo và đơn vị hoàn toàn khác nhau lên cùng một biểu đồ đường với hai trục tung bên trái và bên phải.
  • Hệ quả: Người vẽ có thể tùy tiện co giãn khoảng giá trị của hai trục tung để ép hai đường cong chạy song song hoặc cắt nhau tại bất kỳ điểm nào, ngụy tạo mối tương quan hình thức giữa hai biến số vốn không liên quan.
  • Giải pháp: Thay vì dùng trục kép, hãy vẽ hai biểu đồ con nằm xếp chồng theo chiều dọc (Stacked Subplots) có chung trục hoành thời gian, hoặc chuyển đổi các đại lượng về dạng chỉ số tăng trưởng tương đối (phần trăm thay đổi so với mốc cơ sở).

Cạm bẫy 3: Biểu đồ tròn quá nhiều lát và đồ thị 3D giả lập ​

  • Cơ chế: Biểu đồ tròn mã hóa dữ liệu bằng góc ở tâm và diện tích hình quạt. Khi biểu đồ tròn có nhiều hoặc các lát có kích thước gần tương đồng, mắt người rất khó so sánh chính xác góc và diện tích hình quạt. Đặc biệt, việc lạm dụng hiệu ứng 3D làm biến dạng góc nghiêng: Các lát cắt nằm ở phía trước góc nhìn phối cảnh sẽ bị phóng đại nhân tạo, trong khi các lát cắt ở phía sau bị thu nhỏ lại.
  • Giải pháp: Luôn thay thế biểu đồ tròn phức tạp bằng một biểu đồ thanh ngang được sắp xếp thứ tự giảm dần.

3.2. Tỷ lệ dữ liệu trên mực in ​

Trong tác phẩm The Visual Display of Quantitative Information, Edward Tufte giới thiệu khái niệm Tỷ lệ dữ liệu trên mực in (Data-to-Ink Ratio). Khái niệm này biểu thị tỷ lệ giữa lượng mực dùng để truyền tải thông tin dữ liệu cốt lõi so với tổng lượng mực tạo nên toàn bộ biểu đồ. Một biểu đồ được thiết kế hiệu quả cần tối ưu hóa tỷ lệ này bằng cách:

  • Loại bỏ các chi tiết trang trí rườm rà không mang thông tin (Chartjunk), chẳng hạn như bóng đổ 3D, họa tiết hoa văn không cần thiết, và đường viền khung tranh dày đặc.
  • Tiết chế độ đậm của các đường lưới tọa độ (Gridlines): Đường lưới chỉ đóng vai trò hỗ trợ đọc số liệu, không được phép lấn át các đường biểu diễn dữ liệu chính.

3.3. Bản đồ phân vùng địa lý (Choropleth Maps) và cạm bẫy diện tích ​

Bản đồ Choropleth tô màu các vùng địa lý (như tỉnh thành, quốc gia) theo một chỉ số định lượng (chẳng hạn thu nhập bình quân, tỷ lệ thất nghiệp hoặc mật độ dân số). Quy trình kỹ thuật kết hợp cấu trúc hình học không gian (GeoJSON hoặc Shapefile) với bảng dữ liệu thống kê thông qua phương thức ghép nối dữ liệu.

Tuy nhiên, bản đồ Choropleth cần được đọc cẩn trọng trước Cạm bẫy diện tích địa lý (Geographic Area Trap):

  • Trên bản đồ, những khu vực có diện tích đất đai rộng lớn (như vùng rừng núi hoặc hoang mạc) sẽ chiếm trọn thị giác của người xem nhờ diện tích mảng màu rất lớn.
  • Ngược lại, các trung tâm đô thị đông dân cư và có đóng góp kinh tế trọng yếu lại chỉ là những chấm nhỏ li ti khó nhìn thấy.
  • Nguyên tắc nhận thức: Diện tích đất đai không đồng nghĩa với tầm quan trọng của dữ liệu. Cần cân nhắc sử dụng bản đồ bong bóng (Bubble Map) hoặc bản đồ biến dạng diện tích (Cartogram) khi nghiên cứu các chỉ số gắn liền với dân số.

3.4. Trực quan hóa tương tác với Plotly Express ​

Biểu đồ tĩnh tạo bởi Matplotlib và Seaborn phù hợp cho các báo cáo dạng in ấn và tài liệu học thuật. Trong khi đó, các thư viện trực quan hóa tương tác như Plotly Express cung cấp trải nghiệm khám phá linh hoạt trên môi trường web:

  • Khám phá chi tiết (Hover tooltips): Người dùng có thể rê chuột lên từng điểm dữ liệu để đọc chính xác tọa độ, nhãn định danh và các thuộc tính phụ trợ mà không làm rối mắt bức tranh tổng thể.
  • Thao tác động (Zoom & Pan): Cho phép phóng to vào các cụm dữ liệu dày đặc và di chuyển qua lại giữa các khoảng thời gian.
  • Lựa chọn công cụ: Sử dụng biểu đồ tĩnh khi cần cố định bố cục trang in và xuất bản định dạng vector, còn sử dụng biểu đồ tương tác trong các bảng điều khiển nghiệp vụ (Dashboards) để người dùng có thể lọc và phóng to theo nhu cầu.

4. Tổng kết ​

  1. Tuân thủ kiến trúc hướng đối tượng: Khởi tạo fig, ax = plt.subplots() tường minh để phân định ranh giới giữa khung tranh Figure và hệ tọa độ Axes, giúp kiểm soát chặt chẽ bố cục nhiều thành phần.
  2. Khai thác Seaborn cho đồ họa thống kê: Sử dụng catplot và FacetGrid để phân rã dữ liệu nhiều chiều thành các biểu đồ con trực quan, tránh nhồi nhét quá tải thị giác.
  3. Bảo đảm tính chuẩn mực của biểu đồ cột: Không cắt cụt trục tung của biểu đồ cột và thanh ngang, trục đo chiều dài cần xuất phát từ mốc 0 để bảo toàn tỷ lệ trực quan.
  4. Cảnh giác trước cạm bẫy trực quan: Tránh sử dụng trục tung kép gây ngụy tạo tương quan, đồng thời hạn chế biểu đồ tròn quá nhiều lát và các hiệu ứng 3D làm sai lệch nhận thức hình học.
  5. Tối ưu hóa tỷ lệ dữ liệu trên mực in: Giảm bớt các chi tiết trang trí rườm rà (Chartjunk) và tiết chế độ đậm của đường lưới nhằm làm nổi bật tín hiệu phân tích cốt lõi.
  6. Nhận thức ranh giới của bản đồ Choropleth: Phân biệt rạch ròi giữa diện tích lãnh thổ địa lý với mật độ dân số hoặc quy mô kinh tế của vùng dữ liệu.

Tài liệu tham khảo ​

Đọc hình và đối chiếu với dữ liệu ​

Một lệnh vẽ chạy được mới chỉ tạo ra hình. Ta còn phải kiểm tra hình có trả lời đúng câu hỏi và có cho người đọc thấy các điều kiện so sánh hay không.

Biểu đồ thanh cho số lượng theo nhóm ​

Hình 9.1: Số sản phẩm bán được của ba nhóm Sách, Vở và Bút.
Hình 9.1: Số sản phẩm bán được của ba nhóm Sách, Vở và Bút.

Mỗi thanh biểu diễn một nhóm hàng. Độ dài thanh tính từ mốc 0 biểu diễn số sản phẩm bán được. Thứ tự các nhóm không diễn tả thời gian, vì vậy không nối các đỉnh thanh thành một đường xu hướng.

Bài tập: Tính tỷ trọng từ số lượng

Ba nhóm Sách, Vở và Bút lần lượt bán được 12, 8 và 5 sản phẩm. Hãy tính tổng số sản phẩm và tỷ trọng của từng nhóm. Nếu chuyển biểu đồ sang tỷ trọng, nhãn trục tung phải thay đổi thế nào?

Lời giải

Tổng số sản phẩm là 25. Chia từng số lượng cho 25 rồi nhân 100, ta được 48%, 32% và 20%. Trục đo phải ghi “Tỷ trọng sản phẩm (%)” thay cho “Số sản phẩm”. Tỷ trọng này dùng tổng số sản phẩm làm mẫu số, không dùng số nhóm hàng.

python
import numpy as np
so_luong = np.array([12, 8, 5])
ty_trong = so_luong / so_luong.sum() * 100
assert so_luong.sum() == 25
np.testing.assert_allclose(ty_trong, [48, 32, 20])

Histogram đếm quan sát trong khoảng ​

Hình 9.2: Phân phối mười mức giá với bốn khoảng có bề rộng bằng nhau.
Hình 9.2: Phân phối mười mức giá với bốn khoảng có bề rộng bằng nhau.

Histogram gom các giá trị số vào khoảng rồi đếm. Một cột có thể chứa nhiều giá trị khác nhau, nên không thể đọc lại từng quan sát chỉ từ hình. Trong hình này, các khoảng có cùng bề rộng và trục tung biểu diễn số mặt hàng.

Bài tập: Kiểm tra biên khoảng

Cho dãy giá [10, 12, 14, 16, 20, 20, 24, 28, 30, 36], đơn vị nghìn đồng, và các biên [0, 10, 20, 30, 40]. Hãy tính số quan sát trong từng khoảng theo quy tắc của np.histogram. Hai giá trị 20 thuộc khoảng nào?

Lời giải

Ba khoảng đầu nhận cận trái và không nhận cận phải. Khoảng cuối nhận cả cận phải. Số quan sát lần lượt là 0, 4, 4 và 2. Cả hai giá trị 20 thuộc khoảng từ 20 đến dưới 30, không thuộc khoảng từ 10 đến dưới 20.

python
gia = np.array([10, 12, 14, 16, 20, 20, 24, 28, 30, 36])
tan_so, bien = np.histogram(gia, bins=[0, 10, 20, 30, 40])
assert tan_so.tolist() == [0, 4, 4, 2]
assert tan_so.sum() == len(gia)

Thang đo làm thay đổi độ dài nhìn thấy ​

Hình 9.3: Cùng số lượng 98 và 100 sản phẩm, hai cách đặt mốc đầu trục tạo ra tỷ lệ chiều dài khác nhau.
Hình 9.3: Cùng số lượng 98 và 100 sản phẩm, hai cách đặt mốc đầu trục tạo ra tỷ lệ chiều dài khác nhau.

Ở hình bên trái, chiều dài thanh được đo từ 0. Ở hình bên phải, đoạn được vẽ chỉ bắt đầu từ 97. Đọc số trên trục vẫn cho đúng hai giá trị, nhưng so sánh chiều dài thanh ở hình bên phải không còn là so sánh tỷ lệ 100 với 98.

Bài tập: Phân biệt chênh lệch và tỷ lệ

Tính mức tăng từ 98 lên 100 sản phẩm theo đơn vị sản phẩm và theo phần trăm. Trong hình dùng mốc 97, hai đoạn thanh nhìn thấy có tỷ lệ chiều dài bao nhiêu? Vì sao không được dùng tỷ lệ ấy để nói số sản phẩm tăng gấp ba?

Lời giải

Mức tăng là 2 sản phẩm, tương đương khoảng 2,04% so với 98 sản phẩm ban đầu. Các đoạn thanh trong hình bên phải dài 1 và 3 đơn vị, nên tỷ lệ chiều dài là 3. Chúng biểu diễn phần vượt mốc 97, không biểu diễn toàn bộ số sản phẩm.

python
import math
assert 100 - 98 == 2
assert math.isclose((100 - 98) / 98 * 100, 2.0408163265306123)
assert (100 - 97) / (98 - 97) == 3

Boxplot và các quan sát riêng lẻ ​

Hình 9.4: Boxplot của hai nhóm giá, kèm từng quan sát để thấy rõ cỡ mẫu và điểm nằm ngoài râu.
Hình 9.4: Boxplot của hai nhóm giá, kèm từng quan sát để thấy rõ cỡ mẫu và điểm nằm ngoài râu.

Hộp tóm tắt trung vị và hai tứ phân vị. Với quy tắc râu bằng 1,5 lần khoảng tứ phân vị, ngưỡng lý thuyết và điểm cuối râu là hai đại lượng khác nhau. Điểm cuối râu phải là một quan sát còn nằm trong ngưỡng, không phải chính ngưỡng.

Bài tập: Tính hộp, ngưỡng và râu

Nhóm A có giá [10, 12, 14, 16, 100], còn nhóm B có giá [20, 22, 24, 26, 28]. Hãy tính trung vị, tứ phân vị và ngưỡng dưới, trên của nhóm A bằng quy tắc nội suy mặc định của pandas. Râu trên của A dừng ở đâu? Có đủ căn cứ kết luận 100 là dữ liệu sai không?

Lời giải

Nhóm A có tứ phân vị thứ nhất bằng 12, trung vị bằng 14 và tứ phân vị thứ ba bằng 16. Khoảng tứ phân vị bằng 4, nên hai ngưỡng là 6 và 22. Các quan sát còn trong ngưỡng chạy từ 10 đến 16, vì vậy râu trên dừng ở 16. Giá 100 nằm ngoài râu, nhưng hình chưa cho biết đó là lỗi nhập liệu hay một trường hợp hợp lệ có giá cao.

python
import pandas as pd
nhom_a = pd.Series([10, 12, 14, 16, 100])
q1, trung_vi, q3 = nhom_a.quantile([0.25, 0.5, 0.75])
iqr = q3 - q1
duoi, tren = q1 - 1.5 * iqr, q3 + 1.5 * iqr
trong_nguong = nhom_a[nhom_a.between(duoi, tren)]
assert (q1, trung_vi, q3, iqr, duoi, tren) == (12, 14, 16, 4, 6, 22)
assert (trong_nguong.min(), trong_nguong.max()) == (10, 16)

Lab 12: Biểu đồ cho báo cáo và phương pháp kiểm định cấu trúc hình ảnh ​

Một hình lưu thành công vẫn có thể vẽ sai dữ liệu, thiếu đơn vị hoặc dùng trục gây hiểu nhầm. Ta sẽ tính bảng dùng để vẽ, xuất hình từ mã và kiểm lại cả số liệu lẫn cách trình bày.


1. Mục tiêu và bài toán thực tế ​

Xét bài toán thực tế: Một nhóm chuyên viên phân tích thị trường lưu trú du lịch cần xây dựng bộ trực quan hóa dữ liệu phục vụ báo cáo định kỳ tại vùng đô thị Santiago de Chile. Bản báo cáo phải giải quyết năm thách thức kỹ thuật cốt lõi:

  1. Tổng hợp chuỗi thời gian lượt đánh giá theo tháng của từng quận từ hai bảng dữ liệu liên kết, nhận diện và loại bỏ các kỳ chưa trọn vẹn để tránh đưa ra kết luận sai về xu hướng suy giảm.
  2. Thiết kế biểu đồ đường hoàn chỉnh với bốn thành phần chuẩn mực: Đường số liệu chính xác, tiêu đề nêu thông điệp phân tích rõ ràng, nhãn trục tung kèm đơn vị đo lường, và chú thích nguồn gốc dữ liệu minh bạch ở chân hình.
  3. Thống kê tỷ lệ loại phòng nguyên căn (Entire home/apt) tại các quận đạt ngưỡng quy mô tối thiểu, chuyển đổi thành biểu đồ thanh ngang có nhãn giá trị trực tiếp trên thanh và trục hoành xuất phát từ gốc tọa độ 0.
  4. Khám phá phân phối của chỉ số số ngày mở lịch trong năm (availability_365) bằng biểu đồ tần suất (histogram) với số khoảng chia xác định, bóc tách hai nhóm hành vi trái ngược và làm rõ lý do số bình quân đơn lẻ không thể đại diện cho dữ liệu có phân phối hai đỉnh.
  5. Vạch trần kỹ thuật thao túng thị giác do việc cắt ngắn trục tung gây ra, sửa lại thành biểu đồ cột trung thực và xây dựng hàm kiểm định tự động cấu trúc hình ảnh bằng thư viện kiểm thử assert.

2. Dữ liệu thực hành và chuẩn bị môi trường ​

Để bảo đảm toàn bộ mã nguồn có thể thực thi độc lập và kiểm chứng ngoại tuyến (offline), bài thực hành sử dụng bộ dữ liệu mẫu giả lập chuẩn hóa được thiết kế dựa trên các quy luật thống kê của dữ liệu thực tế tại Santiago:

  1. Bảng chỗ ở mẫu DEMO_DS (60 chỗ ở tại 6 quận: Santiago, Providencia, Las Condes, Ñuñoa, Lo Barnechea và Vitacura) với thông tin phân loại phòng và số ngày mở lịch.
  2. Bảng đánh giá mẫu DEMO_RV (1.905 lượt đánh giá từ ngày 2024-01-10 đến 2026-07-01) liên kết với bảng chỗ ở qua mã định danh listing_id.
  3. Thư mục lưu trữ hình ảnh figures/ được khởi tạo tự động từ mã nguồn.
python
import numpy as np
import pandas as pd
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
from pathlib import Path
from IPython.display import display

# Thiết lập hành vi Copy-on-Write an toàn cho pandas
if pd.__version__.startswith("2.") or pd.__version__.startswith("3."):
    pd.set_option("mode.copy_on_write", True)

# Địa chỉ snapshot dữ liệu thực tế tại Santiago de Chile phục vụ đối chiếu
SNAPSHOT_URL = (
    "https://data.insideairbnb.com/chile/rm/santiago/"
    "2026-06-29/visualisations/listings.csv"
)

# Mốc chụp dữ liệu chuẩn hóa của tập dữ liệu
SNAPSHOT = "2026-06-29"

# Khởi tạo thư mục chứa sản phẩm biểu đồ
Path("figures").mkdir(parents=True, exist_ok=True)

# Cấu hình phong cách đồ họa thống nhất cho toàn bộ bài thực hành
plt.rcParams["svg.fonttype"] = "path"
plt.rcParams.update(
    {
        "axes.spines.top": False,
        "axes.spines.right": False,
        "axes.grid": True,
        "grid.alpha": 0.25,
        "font.size": 11,
    }
)

# 1. Bảng chỗ ở giả lập chuẩn hóa: 60 chỗ ở tại 6 quận với chu kỳ loại phòng cố định
_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)
_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}
DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

# 2. Bảng đánh giá giả lập chuẩn hóa: 1.905 lượt đánh giá liên kết qua listing_id
_dong = []
for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))
DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])

NOTE

Đối chiếu thực tế: Khi xử lý tệp dữ liệu đầy đủ của thành phố Santiago, mốc chụp 2026-06-29 ghi nhận tổng cộng 188 tháng đánh giá tại quận Providencia với 2.810 lượt đánh giá trong tháng 06 năm 2026. Các bài tập dưới đây sử dụng dữ liệu mẫu giả lập nhằm bảo đảm khả năng tái lập và kiểm chứng ngoại tuyến hoàn toàn, tuyệt đối không suy diễn các con số này thành dữ liệu lịch sử của vùng đô thị Santiago.


3. Nội dung chi tiết bộ bài tập thực hành ​

Bài tập 1: Lọc dữ liệu theo quận và tổng hợp chuỗi đánh giá theo tháng ​

Bài tập 1: Tổng hợp chuỗi đánh giá theo tháng

Trong phân tích chuỗi thời gian của từng địa bàn, bảng đánh giá (rv) chỉ lưu mã số chỗ ở (listing_id) và ngày tháng (date), còn tên địa bàn (neighbourhood) lại nằm ở bảng chỗ ở (ds). Cần liên kết hai bảng này để lọc ra các đánh giá thuộc quận mục tiêu, đếm số lượng theo chu kỳ tháng và loại bỏ tháng cuối cùng chưa trọn vẹn.

Hãy viết hàm monthly_reviews(rv: pd.DataFrame, ds: pd.DataFrame, quan: str) -> pd.Series thực hiện các yêu cầu:

  1. Lấy tập hợp các mã định danh id của các chỗ ở thuộc quận quan trong bảng ds.
  2. Lọc các dòng trong bảng rv có listing_id thuộc tập hợp mã định danh vừa tìm được.
  3. Chuyển đổi cột date thành thời gian và tổng hợp số lượng đánh giá theo từng tháng bằng phương thức resample("ME").size().
  4. Loại bỏ phần tử tháng cuối cùng trong chuỗi vì theo giả thiết của biến thể này, kỳ cuối chưa được quan sát đủ, tránh tạo ra ảo giác sụt giảm mạnh về số đánh giá.
  5. Trả về Series với là ngày cuối tháng (DatetimeIndex) và giá trị là số lượt đánh giá nguyên dương. Đảm bảo hàm không làm thay đổi hai đầu vào.
Lời giải

Cách 1: Sử dụng tập hợp set để lọc mã và cắt lát chỉ mục cuối ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])

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

import pandas as pd


def monthly_reviews(rv: pd.DataFrame, ds: pd.DataFrame, quan: str) -> pd.Series:
    # 1. Trích xuất tập hợp các mã định danh thuộc quận mục tiêu
    tap_id = set(ds.loc[ds["neighbourhood"] == quan, "id"])

    # 2. Lọc các đánh giá có mã chỗ ở thuộc tập hợp trên
    rv_quan = rv[rv["listing_id"].isin(tap_id)]

    # 3. Đặt cột date làm chỉ mục và gom nhóm theo ngày cuối tháng
    thang = rv_quan.set_index("date").resample("ME").size()

    # 4. Loại bỏ tháng cuối cùng chưa trọn vẹn bằng cú pháp cắt lát
    return thang.iloc[:-1]


# Gọi thực nghiệm hàm với dữ liệu mẫu:
thang_pro_c1 = monthly_reviews(DEMO_RV, DEMO_DS, "Providencia")
display(
    f"Số tháng hợp lệ: {len(thang_pro_c1)}, tháng cuối: {thang_pro_c1.index[-1]:%Y-%m-%d}, số lượt: {thang_pro_c1.iloc[-1]}"
)

# Kết thúc lời giải

Giải thích cách 1 ​

Lấy tập ID chỗ ở thuộc quận rồi lọc review theo isin. resample("ME").size() đếm theo tháng và giữ các tháng nằm giữa hai đầu chuỗi. Bài này yêu cầu bỏ phần tử cuối theo giả thiết kỳ cuối chưa trọn. Không dùng thao tác đó cho mọi tập dữ liệu nếu chưa kiểm mốc quan sát.

Kết quả cách 1 ​

Kết quả
Số tháng hợp lệ: 30, tháng cuối: 2026-06-30, số lượt: 15

Cách 2: Sử dụng chuỗi phương thức kết hợp phép lọc boolean trên chỉ mục ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])

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

import pandas as pd


def monthly_reviews(rv: pd.DataFrame, ds: pd.DataFrame, quan: str) -> pd.Series:
    # Lọc mã chỗ ở và nối chuỗi phương thức resample để tối ưu bộ nhớ
    danh_sach_id = ds.loc[ds["neighbourhood"] == quan, "id"].to_numpy()
    chuoi_thang = (
        rv.loc[rv["listing_id"].isin(danh_sach_id)]
        .set_index("date")
        .resample("ME")
        .size()
    )
    # Loại bỏ kỳ cuối cùng nếu chuỗi có từ hai mốc thời gian trở lên
    if len(chuoi_thang) >= 2:
        return chuoi_thang.iloc[:-1]
    return chuoi_thang


# Gọi thực nghiệm hàm với dữ liệu mẫu:
thang_pro_c2 = monthly_reviews(DEMO_RV, DEMO_DS, "Providencia")
display(
    f"Cách 2: Tổng số đánh giá = {thang_pro_c2.sum()}, độ dài chuỗi = {len(thang_pro_c2)}"
)

# Kết thúc lời giải

Giải thích cách 2 ​

Chuỗi phương thức gắn các bước lọc, đặt và tổng hợp trong một biểu thức. Mã quận được xác định từ bảng chỗ ở, không lấy từ vị trí dòng của review. Cách đếm vẫn là số đánh giá, không phải số đánh giá hoặc doanh thu của quận.

Kết quả cách 2 ​

Kết quả
Cách 2: Tổng số đánh giá = 391, độ dài chuỗi = 30

So sánh cách dùng ​

Cách 1 mở rõ bước lọc quận, tổng hợp tháng và bỏ kỳ cuối theo yêu cầu đã cho. Cách 2 gom việc lọc và tổng hợp thành chuỗi phương thức. Khi thay dữ liệu, vẫn phải đối chiếu snapshot thay vì mặc nhiên coi mọi tháng còn lại đều trọn vẹn.


Bài tập 2: Kiểm soát mốc chụp dữ liệu và nhận diện kỳ chưa trọn ​

Bài tập 2: Lọc theo mốc chụp dữ liệu

Khi cập nhật dữ liệu từ các đợt phát hành định kỳ (snapshot releases), nhiều bản ghi có thể chứa ngày đánh giá rơi vào sau ngày công bố mốc chụp do độ trễ đồng bộ hệ thống, hoặc bản thân ngày chụp rơi vào giữa kỳ báo cáo (Ví dụ: Ngày 29 tháng 6 khiến tháng 6 thiếu 1 ngày). Cần xây dựng cơ chế kiểm soát mốc chụp tường minh để bảo đảm dữ liệu đưa vào biểu đồ hoàn toàn nhất quán.

Hãy viết hàm monthly_reviews_with_cutoff(rv: pd.DataFrame, ds: pd.DataFrame, quan: str, snapshot: str = "2026-06-29") -> pd.Series thực hiện:

  1. Lọc bảng rv chỉ giữ các dòng có date nhỏ hơn hoặc bằng mốc thời gian snapshot.
  2. Lọc các chỗ ở thuộc quận quan và tổng hợp theo tháng bằng resample("ME").size().
  3. Kiểm tra xem mốc snapshot có trùng với ngày cuối tháng hay không. Nếu snapshot rơi vào trước ngày cuối tháng của tháng đó, tháng đó chưa hoàn chỉnh và bắt buộc phải loại bỏ khỏi chuỗi báo cáo.
  4. Trả về Series chuỗi tháng đã chuẩn hóa và không chứa kỳ cụt.
Lời giải

Cách 1: So sánh điều kiện boolean và kiểm tra ngày kết thúc tháng ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])

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

import pandas as pd


def monthly_reviews_with_cutoff(
    rv: pd.DataFrame, ds: pd.DataFrame, quan: str, snapshot: str = "2026-06-29"
) -> pd.Series:
    snap_dt = pd.Timestamp(snapshot)
    tap_id = set(ds.loc[ds["neighbourhood"] == quan, "id"])

    # 1. Lọc bản ghi nằm trong mốc chụp và thuộc địa bàn yêu cầu
    dieu_kien = (rv["listing_id"].isin(tap_id)) & (rv["date"] <= snap_dt)
    rv_hop_le = rv.loc[dieu_kien]

    # 2. Gom nhóm theo tháng
    thang = rv_hop_le.set_index("date").resample("ME").size()

    # 3. Kiểm tra xem mốc chụp có rơi vào trước ngày cuối tháng cuối cùng không
    if len(thang) > 0 and snap_dt < thang.index[-1]:
        return thang.iloc[:-1]
    return thang


# Gọi thực nghiệm hàm với dữ liệu mẫu:
thang_cutoff_c1 = monthly_reviews_with_cutoff(
    DEMO_RV, DEMO_DS, "Providencia", "2026-06-29"
)
display(
    f"Mốc cắt 2026-06-29: Số tháng = {len(thang_cutoff_c1)}, tháng cuối = {thang_cutoff_c1.index[-1]:%Y-%m-%d}"
)

# Kết thúc lời giải

Giải thích cách 1 ​

Lọc ngày theo snapshot trước khi tổng hợp. So sánh mốc quan sát với ngày cuối tháng để quyết định kỳ nào đã đầy đủ. Một ngày có sự kiện cuối cùng không tự xác định thời điểm dữ liệu đã quan sát đến đâu.

Kết quả cách 1 ​

Kết quả
Mốc cắt 2026-06-29: Số tháng = 29, tháng cuối = 2026-05-31

Cách 2: Sử dụng cắt lát chỉ mục thời gian và offset MonthEnd ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])

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

import pandas as pd


def monthly_reviews_with_cutoff(
    rv: pd.DataFrame, ds: pd.DataFrame, quan: str, snapshot: str = "2026-06-29"
) -> pd.Series:
    snap_dt = pd.Timestamp(snapshot)
    danh_sach_id = ds.loc[ds["neighbourhood"] == quan, "id"].to_numpy()

    # Cắt lát chuỗi thời gian trực tiếp trên bảng đánh giá đã sắp xếp
    rv_loc = (
        rv.loc[rv["listing_id"].isin(danh_sach_id)]
        .sort_values("date")
        .set_index("date")
        .loc[:snap_dt]
    )
    thang = rv_loc.resample("ME").size()

    # Xác định ngày cuối tháng trọn vẹn gần nhất
    ngay_cuoi_thang_chuan = (
        snap_dt - pd.offsets.MonthEnd(1)
        if snap_dt < (snap_dt + pd.offsets.MonthEnd(0))
        else snap_dt
    )
    return thang.loc[:ngay_cuoi_thang_chuan]


# Gọi thực nghiệm hàm với dữ liệu mẫu:
thang_cutoff_c2 = monthly_reviews_with_cutoff(
    DEMO_RV, DEMO_DS, "Providencia", "2026-06-29"
)
display(
    f"Cách 2: Tháng cuối trọn vẹn = {thang_cutoff_c2.index[-1]:%Y-%m-%d}, số lượt = {thang_cutoff_c2.iloc[-1]}"
)

# Kết thúc lời giải

Giải thích cách 2 ​

Đặt quy tắc giữ kỳ thành mặt nạ riêng giúp kiểm rõ cận thời gian. Hai cách phải cùng quy ước về ngày kết thúc, đặc biệt khi snapshot đúng ngày cuối tháng. Nếu ngày có giờ, cần xác định mốc là đầu ngày hay hết ngày thay vì so sánh ngầm theo nửa đêm.

Kết quả cách 2 ​

Kết quả
Cách 2: Tháng cuối trọn vẹn = 2026-05-31, số lượt = 9

So sánh cách dùng ​

Cách 1 đặt bước kiểm tra snapshot gần thao tác tổng hợp để dễ đối chiếu mốc thời gian. Cách 2 làm rõ việc giữ các tháng đã hoàn tất. Chọn cách trình bày nào cũng phải phân biệt tháng chưa đủ ngày với tháng có số đánh giá thấp.


Bài tập 3: Dựng biểu đồ đường hoàn chỉnh cho chuỗi thời gian ​

Bài tập 3: Dựng biểu đồ đường đạt chuẩn xuất bản

Biểu đồ cần vẽ đúng chuỗi số liệu, có nhãn đơn vị và thông tin nguồn. Tiêu đề nêu một điều người đọc có thể kiểm ngay trên hình.

Hãy viết hàm plot_line(thang: pd.Series, path: str) -> matplotlib.axes.Axes thực hiện các yêu cầu:

  1. Khởi tạo khung hình mới bằng giao diện hướng đối tượng fig, ax = plt.subplots(figsize=(9, 3.5)).
  2. Vẽ một đường duy nhất thể hiện diễn biến của thang qua các mốc thời gian bằng ax.plot(thang.index, thang.values).
  3. Đặt tiêu đề cho biểu đồ nêu bật thông điệp phân tích (độ dài tiêu đề tối thiểu 15 ký tự).
  4. Thiết lập nhãn trục tung có ghi rõ đơn vị tính (Lượt đánh giá (lượt/tháng)).
  5. Ghi chú thích nguồn số liệu ở góc dưới khung hình bằng phương thức fig.text(...) với nội dung không rỗng.
  6. Lưu tệp hình ảnh vào đường dẫn path với độ phân giải dpi=150, tùy chọn bbox_inches="tight" và trả về đối tượng ax.
Lời giải

Cách 1: Thiết lập từng thuộc tính qua giao diện hướng đối tượng cơ bản ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])


def monthly_reviews(rv: pd.DataFrame, ds: pd.DataFrame, quan: str) -> pd.Series:
    # 1. Trích xuất tập hợp các mã định danh thuộc quận mục tiêu
    tap_id = set(ds.loc[ds["neighbourhood"] == quan, "id"])

    # 2. Lọc các đánh giá có mã chỗ ở thuộc tập hợp trên
    rv_quan = rv[rv["listing_id"].isin(tap_id)]

    # 3. Đặt cột date làm chỉ mục và gom nhóm theo ngày cuối tháng
    thang = rv_quan.set_index("date").resample("ME").size()

    # 4. Loại bỏ tháng cuối cùng chưa trọn vẹn bằng cú pháp cắt lát
    return thang.iloc[:-1]


thang_pro_c1 = monthly_reviews(DEMO_RV, DEMO_DS, "Providencia")

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt


def plot_line(thang: pd.Series, path: str):
    # 1. Khởi tạo khung hình với kích thước chuẩn tỷ lệ ngang
    fig, ax = plt.subplots(figsize=(9, 3.5))

    # 2. Vẽ đường chuỗi thời gian
    ax.plot(
        thang.index,
        thang.values,
        color="#1976d2",
        linewidth=2,
        marker="o",
        markersize=3,
    )

    # 3. Đặt tiêu đề nêu thông điệp phân tích cụ thể
    ax.set_title(
        "Diễn biến số lượt đánh giá theo tháng tại quận Providencia (2024–2026)", pad=12
    )

    # 4. Gán nhãn trục tung kèm đơn vị đo lường
    ax.set_ylabel("Lượt đánh giá (lượt/tháng)")
    ax.set_xlabel("Thời gian ghi nhận")

    # 5. Chú thích nguồn gốc dữ liệu ở chân hình
    fig.text(
        0.12,
        -0.02,
        "Nguồn: Dữ liệu Inside Airbnb Santiago (mẫu giả lập chuẩn hóa 2024–2026)",
        fontsize=9,
        color="#555555",
    )

    # 6. Lưu tệp hình ảnh và trả về đối tượng Axes
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_line_c1 = plot_line(thang_pro_c1, "figures/providencia_thang_c1.png")
display(
    f"Đã lưu biểu đồ: Tiêu đề = '{ax_line_c1.get_title()}', nhãn y = '{ax_line_c1.get_ylabel()}'"
)

# Kết thúc lời giải

Giải thích cách 1 ​

subplots tạo Figure chứa Axes mới, rồi plot dùng tháng làm trục ngang và số đếm làm trục dọc. Ghi nhãn đơn vị và phạm vi dữ liệu để người đọc hiểu mỗi điểm. savefig ghi chính Figure này, tránh lưu nhầm hình đang hoạt động khác.

Kết quả cách 1 ​

Kết quả
Đã lưu biểu đồ: Tiêu đề = 'Diễn biến số lượt đánh giá theo tháng tại quận Providencia (2024–2026)', nhãn y = 'Lượt đánh giá (lượt/tháng)'
Diễn biến số lượt đánh giá theo tháng tại quận Providencia
Diễn biến số lượt đánh giá theo tháng tại quận Providencia

Cách 2: Tối ưu hóa lưới gióng và định dạng hiển thị ngày tháng ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

_dong = []

for k, lid in enumerate(DEMO_DS["id"]):
    for ngay in pd.date_range("2024-01-10", "2026-07-01", freq=f"{20 + k % 25}D"):
        _dong.append((lid, ngay))

DEMO_RV = pd.DataFrame(_dong, columns=["listing_id", "date"])


def monthly_reviews(rv: pd.DataFrame, ds: pd.DataFrame, quan: str) -> pd.Series:
    # 1. Trích xuất tập hợp các mã định danh thuộc quận mục tiêu
    tap_id = set(ds.loc[ds["neighbourhood"] == quan, "id"])

    # 2. Lọc các đánh giá có mã chỗ ở thuộc tập hợp trên
    rv_quan = rv[rv["listing_id"].isin(tap_id)]

    # 3. Đặt cột date làm chỉ mục và gom nhóm theo ngày cuối tháng
    thang = rv_quan.set_index("date").resample("ME").size()

    # 4. Loại bỏ tháng cuối cùng chưa trọn vẹn bằng cú pháp cắt lát
    return thang.iloc[:-1]


thang_pro_c1 = monthly_reviews(DEMO_RV, DEMO_DS, "Providencia")

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt
import matplotlib.dates as mdates


def plot_line(thang: pd.Series, path: str):
    fig, ax = plt.subplots(figsize=(9, 3.5))

    # Vẽ đường với kiểu nét thanh thoát và điểm nút trực quan
    ax.plot(
        thang.index,
        thang.values,
        color="#0d47a1",
        linewidth=1.8,
        marker="s",
        markersize=3.5,
        label="Số lượt đánh giá",
    )

    # Tinh chỉnh định dạng trục thời gian hiển thị tháng/năm
    ax.xaxis.set_major_formatter(mdates.DateFormatter("%m/%Y"))
    ax.xaxis.set_major_locator(mdates.MonthLocator(interval=4))

    ax.set_title(
        "Số lượt đánh giá theo tháng tại quận Providencia duy trì đà tăng trưởng",
        pad=12,
    )
    ax.set_ylabel("Số lượt đánh giá (lượt/tháng)")
    ax.set_xlabel("Thời gian ghi nhận")

    # Gắn chú thích nguồn ở góc trái dưới
    fig.text(
        0.12,
        -0.03,
        "Nguồn: Inside Airbnb Santiago, dữ liệu chuẩn hóa phục vụ phân tích",
        fontsize=9,
        color="#424242",
    )

    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_line_c2 = plot_line(thang_pro_c1, "figures/providencia_thang_c2.png")
display(f"Cách 2: Đã lưu biểu đồ với {len(ax_line_c2.lines)} đường số liệu")

# Kết thúc lời giải

Giải thích cách 2 ​

Gom thiết lập nhãn, nguồn và lưu hình trong một hàm giúp tạo lại biểu đồ theo cùng quy tắc. bbox_inches="tight" thu khung theo nội dung, còn dpi ảnh hưởng bản raster. Tiêu đề phải được dữ liệu hỗ trợ, không dùng câu quảng cáo để làm đường biến động có vẻ mạnh hơn.

Kết quả cách 2 ​

Kết quả
Cách 2: Đã lưu biểu đồ với 1 đường số liệu
Diễn biến số lượt đánh giá theo tháng tại quận Providencia
Diễn biến số lượt đánh giá theo tháng tại quận Providencia

So sánh cách dùng ​

Bạn nên chọn Cách 1 khi vẽ nhanh đồ thị đường đơn giản để khảo sát xu hướng chuỗi thời gian trong quá trình khám phá. Cách 2 cần thiết khi muốn chuẩn hóa lưới trục, nhãn ngày tháng và đóng gói hàm vẽ nhằm xuất hình ảnh sắc nét cho báo cáo.


Bài tập 4: Thống kê số lượng chỗ ở và tỷ lệ loại phòng nguyên căn theo quận ​

Bài tập 4: Lập bảng tỷ lệ phân loại phòng

Để so sánh cơ cấu thị trường lưu trú giữa các địa bàn, cần tính toán tỷ lệ chỗ ở thuộc loại căn hộ hoặc nhà nguyên căn (Entire home/apt). Các quận có quy mô quá nhỏ (quá ít chỗ ở) thường có phương sai lớn và gây nhiễu cho biểu đồ, vì vậy cần áp dụng ngưỡng lọc quy mô min_n và sắp xếp tỷ lệ tăng dần để sẵn sàng cho biểu đồ thanh ngang.

Hãy viết hàm share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame thực hiện:

  1. Nhóm dữ liệu theo cột quận neighbourhood.
  2. Tính hai chỉ số cho mỗi quận:
    • n: Số lượng chỗ ở thuộc quận (tương đương kích thước nhóm).
    • pct: Tỷ lệ chỗ ở có room_type == "Entire home/apt" trên thang đo từ 0,0 đến 1,0 (trung bình của điều kiện boolean).
  3. Lọc bỏ các quận có quy mô n < min_n.
  4. Sắp xếp các quận còn lại theo giá trị pct tăng dần.
  5. Trả về có là tên quận và đúng hai cột ["n", "pct"]. Tuyệt đối không thêm cột phụ làm biến đổi DataFrame ds ban đầu.
Lời giải

Cách 1: Sử dụng groupby với hàm tổng hợp tùy biến tường minh ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

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

import pandas as pd


def share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame:
    # 1. Gom nhóm và tính toán độc lập số lượng và tỷ lệ nguyên căn
    tk = ds.groupby("neighbourhood").agg(
        n=("id", "size"),
        pct=("room_type", lambda cot: (cot == "Entire home/apt").mean()),
    )

    # 2. Lọc theo ngưỡng số lượng tối thiểu và sắp xếp tăng dần theo tỷ lệ
    tk_loc = tk.loc[tk["n"] >= min_n].sort_values("pct", ascending=True)
    return tk_loc


# Gọi thực nghiệm hàm với dữ liệu mẫu (ngưỡng min_n = 5):
tk_nguyen_can_c1 = share_entire(DEMO_DS, min_n=5)
display(tk_nguyen_can_c1)

# Kết thúc lời giải

Giải thích cách 1 ​

Nhóm theo quận rồi tính số dòng và tỷ lệ room_type == "Entire home/apt". Lọc n >= min_n sau khi tổng hợp để ngưỡng áp dụng cho quy mô quận. Tỷ lệ ở thang 0–1, nên 0,8 tương ứng 80%, không phải 0,8%.

Kết quả cách 1 ​

neighbourhoodnpct
Ñuñoa80.5
Santiago200.65
Providencia120.75
Las Condes100.8
Lo Barnechea61

Cách 2: Sử dụng phép gán tạm thời không biến đổi dữ liệu nguồn ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

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

import pandas as pd


def share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame:
    # Sử dụng assign trên bản sao để tính toán vector hóa nhanh chóng
    tk = (
        ds.assign(la_nguyen_can=ds["room_type"] == "Entire home/apt")
        .groupby("neighbourhood")
        .agg(n=("id", "size"), pct=("la_nguyen_can", "mean"))
    )
    return tk.loc[tk["n"] >= min_n].sort_values("pct")


# Gọi thực nghiệm hàm với dữ liệu mẫu:
tk_nguyen_can_c2 = share_entire(DEMO_DS, min_n=5)
display(
    f"Cách 2: Quận dẫn đầu tỷ lệ nguyên căn là {tk_nguyen_can_c2.index[-1]} ({tk_nguyen_can_c2['pct'].max():.1%})"
)

# Kết thúc lời giải

Giải thích cách 2 ​

Tạo cột boolean rồi dùng named aggregation giúp tên n và pct đi cùng định nghĩa của chúng. Sắp tỷ lệ tăng dần là thứ tự dữ liệu cho biểu đồ thanh ngang tiếp theo. Mẫu số cần giữ đúng số dòng trong quận, kể cả khi một kiểu phòng không xuất hiện.

Kết quả cách 2 ​

Kết quả
Cách 2: Quận dẫn đầu tỷ lệ nguyên căn là Lo Barnechea (100.0%)

So sánh cách dùng ​

Hãy chọn Cách 1 khi bạn cần viết hàm tổng hợp tùy biến để trực tiếp kiểm soát công thức tính tỷ lệ trên từng nhóm dữ liệu. Cách 2 thuận tiện hơn khi muốn dùng named aggregation để đặt tên cột rõ ràng và sắp xếp sẵn thứ tự cho biểu đồ thanh.


Bài tập 5: Dựng biểu đồ thanh ngang có nhãn tỷ lệ phần trăm và gốc tọa độ chuẩn ​

Bài tập 5: Dựng biểu đồ thanh ngang chuẩn mực

Biểu đồ thanh ngang cho phép giữ tên quận dễ đọc. Trong bài này, trục bắt đầu từ 0 và mỗi thanh có nhãn giá trị để đối chiếu trực tiếp với bảng.

Hãy viết hàm plot_share_barh(tk: pd.DataFrame, path: str) -> matplotlib.axes.Axes thực hiện:

  1. Nhận bảng thống kê tk từ Bài tập 4 và đường dẫn tệp lưu trữ path.
  2. Khởi tạo khung hình fig, ax = plt.subplots(figsize=(7, 4)).
  3. Vẽ các thanh ngang bằng ax.barh theo đúng thứ tự dòng của tk, với chiều dài thanh là pct * 100 (thang phần trăm %).
  4. Gắn nhãn giá trị trực tiếp trên mỗi thanh bằng phương thức ax.bar_label(..., fmt="%.0f%%").
  5. Thiết lập trục hoành bắt buộc bắt đầu từ 0 (ax.set_xlim(left=0)), nhãn trục hoành có đơn vị %.
  6. Tiêu đề nêu thông điệp phân tích rõ ràng (độ dài tối thiểu 15 ký tự).
  7. Lưu tệp hình ảnh vào path và trả về đối tượng ax.
Lời giải

Cách 1: Sử dụng barh cơ bản và bar_label tự động ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)


def share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame:
    # 1. Gom nhóm và tính toán độc lập số lượng và tỷ lệ nguyên căn
    tk = ds.groupby("neighbourhood").agg(
        n=("id", "size"),
        pct=("room_type", lambda cot: (cot == "Entire home/apt").mean()),
    )

    # 2. Lọc theo ngưỡng số lượng tối thiểu và sắp xếp tăng dần theo tỷ lệ
    tk_loc = tk.loc[tk["n"] >= min_n].sort_values("pct", ascending=True)
    return tk_loc


tk_nguyen_can_c1 = share_entire(DEMO_DS, min_n=5)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt


def plot_share_barh(tk: pd.DataFrame, path: str):
    # 1. Khởi tạo khung hình
    fig, ax = plt.subplots(figsize=(7, 4))

    # 2. Vẽ thanh ngang với độ dài thang đo phần trăm
    thanh = ax.barh(tk.index, tk["pct"] * 100, color="#2b5c8f", height=0.65)

    # 3. Gắn nhãn giá trị trực tiếp trên từng thanh
    ax.bar_label(thanh, fmt="%.0f%%", padding=4, fontsize=10)

    # 4. Bắt buộc trục hoành xuất phát từ 0 và mở rộng khoảng đệm bên phải
    ax.set_xlim(left=0, right=115)
    ax.set_xlabel("Tỷ lệ phòng nguyên căn (%)")

    # 5. Tiêu đề phản ánh thông điệp phân tích
    ax.set_title("Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên", pad=12)

    # 6. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_barh_c1 = plot_share_barh(tk_nguyen_can_c1, "figures/pct_nguyen_can_c1.png")
display(
    f"Đã lưu biểu đồ thanh ngang: Số thanh = {len(ax_barh_c1.patches)}, giới hạn trục x = {ax_barh_c1.get_xlim()}"
)

# Kết thúc lời giải

Giải thích cách 1 ​

barh tạo một thanh cho mỗi quận theo thứ tự bảng. Nhân tỷ lệ với 100 trước khi gắn nhãn phần trăm. Trục phải bắt đầu từ 0 để độ dài thanh phản ánh tỷ lệ, còn nhãn giá trị cho phép đọc con số mà không cần đo bằng mắt.

Kết quả cách 1 ​

Kết quả
Đã lưu biểu đồ thanh ngang: Số thanh = 5, giới hạn trục x = (np.float64(0.0), np.float64(115.0))
Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên
Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên

Cách 2: Tùy biến màu sắc phân cấp và định dạng lề nhãn chữ ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)


def share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame:
    # 1. Gom nhóm và tính toán độc lập số lượng và tỷ lệ nguyên căn
    tk = ds.groupby("neighbourhood").agg(
        n=("id", "size"),
        pct=("room_type", lambda cot: (cot == "Entire home/apt").mean()),
    )

    # 2. Lọc theo ngưỡng số lượng tối thiểu và sắp xếp tăng dần theo tỷ lệ
    tk_loc = tk.loc[tk["n"] >= min_n].sort_values("pct", ascending=True)
    return tk_loc


tk_nguyen_can_c1 = share_entire(DEMO_DS, min_n=5)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt


def plot_share_barh(tk: pd.DataFrame, path: str):
    fig, ax = plt.subplots(figsize=(7, 4))

    # Phân cấp màu sắc nổi bật cho quận có tỷ lệ cao nhất
    mau_sac = ["#4a7bb0" if p < tk["pct"].max() else "#1a4971" for p in tk["pct"]]
    thanh = ax.barh(tk.index, tk["pct"] * 100, color=mau_sac, height=0.6)

    ax.bar_label(thanh, fmt="%.1f%%", padding=5, color="#111111", weight="bold")
    ax.set_xlim(0, 115)
    ax.set_xlabel("Tỷ lệ phòng nguyên căn (%)")
    ax.set_title("Cơ cấu loại phòng nguyên căn phân hóa mạnh giữa các địa bàn", pad=12)

    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_barh_c2 = plot_share_barh(tk_nguyen_can_c1, "figures/pct_nguyen_can_c2.png")
display(f"Cách 2: Đã lưu biểu đồ với {len(ax_barh_c2.texts)} nhãn giá trị số liệu")

# Kết thúc lời giải

Giải thích cách 2 ​

Thiết lập nhãn và lưu hình trong cùng hàm giữ biểu đồ tái lập. bar_label đặt chuỗi lên từng thanh theo đúng container trả về. Nếu nhãn sát mép, mở thêm khoảng trống trong giới hạn trục thay vì cắt chữ hoặc giảm tỷ lệ chữ quá nhỏ.

Kết quả cách 2 ​

Kết quả
Cách 2: Đã lưu biểu đồ với 5 nhãn giá trị số liệu
Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên
Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên

So sánh cách dùng ​

Bạn nên chọn Cách 1 khi muốn tạo nhanh biểu đồ thanh ngang với thang đo bắt đầu từ 0 để so sánh trung thực các nhóm. Cách 2 thích hợp khi cần trau chuốt đồ thị để xuất bản, phân tầng màu sắc nhấn mạnh và căn chỉnh lề để nhãn không bị tràn.


Bài tập 6: Dựng biểu đồ tần suất số ngày mở lịch và trích xuất hai nhóm hành vi ​

Bài tập 6: Dựng histogram và nhận diện phân phối hai đỉnh

Chỉ số availability_365 đại diện cho số ngày một chỗ ở mở lịch cho phép đặt phòng trong vòng 365 ngày tới. Phân phối của biến số này thường có dạng hai đỉnh (bimodal) rất đặc thù, tập trung dày đặc ở hai cực: Các chỗ ở khóa lịch hoàn toàn (0 ngày) và các chỗ ở mở lịch gần như quanh năm (≥360 ngày). Cần sử dụng biểu đồ tần suất (histogram) với số lượng khoảng chia xác định để làm nổi bật hiện tượng này, đồng thời trích xuất định lượng số lượng của hai nhóm hành vi.

Hãy viết hàm plot_availability(avail: pd.Series, path: str, bins: int = 40) -> dict thực hiện:

  1. Loại bỏ các giá trị khuyết (NaN) trong Series avail.
  2. Khởi tạo khung hình và vẽ biểu đồ tần suất bằng ax.hist(..., bins=bins).
  3. Đặt tiêu đề nêu rõ hiện tượng phân cực quan sát được (độ dài tối thiểu 15 ký tự).
  4. Lưu tệp hình ảnh vào đường dẫn path.
  5. Đếm số lượng chỗ ở có đúng 0 ngày mở lịch (so_khoa) và số lượng chỗ ở có từ 360 ngày mở lịch trở lên (so_mo_quanh_nam).
  6. Trả về từ điển gồm đúng ba khóa: "ax" (đối tượng Axes), "so_khoa" (kiểu số nguyên int), và "so_mo_quanh_nam" (kiểu số nguyên int).
Lời giải

Cách 1: Sử dụng dropna và đếm bằng phép so sánh boolean trực tiếp ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import pandas as pd
import matplotlib.pyplot as plt


def plot_availability(avail: pd.Series, path: str, bins: int = 40) -> dict:
    # 1. Loại bỏ ô khuyết để tránh cảnh báo phân tích
    du_lieu = avail.dropna()

    # 2. Khởi tạo khung hình và vẽ biểu đồ tần suất
    fig, ax = plt.subplots(figsize=(8, 3.5))
    ax.hist(du_lieu, bins=bins, color="#0288d1", edgecolor="white", linewidth=0.5)

    # 3. Đặt tiêu đề và các nhãn trục
    ax.set_title(
        "Phân phối số ngày mở lịch trong năm tới: Tập trung tại hai cực", pad=12
    )
    ax.set_xlabel("Số ngày mở lịch trong năm (ngày)")
    ax.set_ylabel("Số lượng chỗ ở")

    # 4. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")

    # 5. Đếm số lượng hai nhóm hành vi đặc thù
    so_khoa = int((du_lieu == 0).sum())
    so_mo_quanh_nam = int((du_lieu >= 360).sum())

    return {
        "ax": ax,
        "so_khoa": so_khoa,
        "so_mo_quanh_nam": so_mo_quanh_nam,
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
kq_hist_c1 = plot_availability(
    DEMO_DS["availability_365"], "figures/availability_hist_c1.png", bins=40
)
display(
    f"Kết quả: {kq_hist_c1['so_khoa']} chỗ ở khóa lịch, {kq_hist_c1['so_mo_quanh_nam']} chỗ ở mở gần quanh năm"
)

# Kết thúc lời giải

Giải thích cách 1 ​

Bỏ ô thiếu trước khi đưa availability_365 vào histogram. bins quyết định cách chia khoảng, còn tổng tần suất phải bằng số giá trị hợp lệ. Đếm riêng giá trị 0 và từ 360 trở lên theo đúng cận của đề.

Kết quả cách 1 ​

Kết quả
Kết quả: 7 chỗ ở khóa lịch, 13 chỗ ở mở gần quanh năm
Phân phối số ngày mở lịch trong năm tới
Phân phối số ngày mở lịch trong năm tới

Cách 2: Xác định ranh giới khoảng chia tường minh bằng NumPy ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


def plot_availability(avail: pd.Series, path: str, bins: int = 40) -> dict:
    mang_du_lieu = avail.dropna().to_numpy()

    fig, ax = plt.subplots(figsize=(8, 3.5))
    # Tạo lưới ranh giới khoảng chia đều từ 0 đến 365
    ranh_gioi_bin = np.linspace(0, 365, bins + 1)
    ax.hist(
        mang_du_lieu,
        bins=ranh_gioi_bin,
        color="#0097a7",
        edgecolor="#ffffff",
        linewidth=0.5,
    )

    ax.set_title(
        "Phân phối hai đỉnh của số ngày mở lịch phản ánh hai nhóm vận hành", pad=12
    )
    ax.set_xlabel("Số ngày mở lịch trong năm (ngày)")
    ax.set_ylabel("Số lượng chỗ ở")

    fig.savefig(path, dpi=150, bbox_inches="tight")

    # Đếm nhanh bằng np.count_nonzero
    so_khoa = int(np.count_nonzero(mang_du_lieu == 0))
    so_mo_quanh_nam = int(np.count_nonzero(mang_du_lieu >= 360))

    return {
        "ax": ax,
        "so_khoa": so_khoa,
        "so_mo_quanh_nam": so_mo_quanh_nam,
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
kq_hist_c2 = plot_availability(
    DEMO_DS["availability_365"], "figures/availability_hist_c2.png", bins=40
)
display(f"Cách 2: Số thanh trên biểu đồ = {len(kq_hist_c2['ax'].patches)}")

# Kết thúc lời giải

Giải thích cách 2 ​

Hàm trả cả Axes và hai số đếm giúp kiểm đối chiếu hình với bảng. Hai cụm trong histogram mô tả phân bố số ngày mở lịch của dữ liệu, không tự cho biết ý định chủ nhà hoặc số ngày thực sự có khách. Phải xem cả lựa chọn khoảng chia trước khi kết luận hình có hai đỉnh.

Kết quả cách 2 ​

Kết quả
Cách 2: Số thanh trên biểu đồ = 40
Phân phối số ngày mở lịch trong năm tới
Phân phối số ngày mở lịch trong năm tới

So sánh cách dùng ​

Cách 1 thuận tiện để dựng histogram và kiểm tổng tần suất trên số quan sát hợp lệ. Cách 2 trả thêm số đếm tại hai vùng biên cùng đối tượng Axes để đối chiếu bảng với hình. Cách chia khoảng vẫn phải được giữ rõ trong báo cáo.


Bài tập 7: Phân tích phân phối hai đỉnh và rủi ro khi chỉ dựa vào số bình quân ​

Bài tập 7: Phân tích bản chất phân phối hai đỉnh

Khi dữ liệu có phân phối hai đỉnh (bimodal distribution), các số đo tóm tắt truyền thống như giá trị trung bình cộng (mean) hoặc trung vị (median) thường rơi vào khoảng giữa, nơi hầu như không có quan sát thực tế nào tồn tại. Hơn nữa, việc hiểu sai bản chất của availability_365 có thể dẫn đến những kết luận sai lệch về thị trường.

Hãy viết hàm analyze_bimodal_distribution(avail: pd.Series) -> dict tính toán các chỉ số phân tích định lượng:

  1. Giá trị trung bình (mean) và trung vị (median) của chuỗi dữ liệu.
  2. Tỷ lệ phần trăm chỗ ở thuộc nhóm khóa lịch hoàn toàn (=0 ngày).
  3. Tỷ lệ phần trăm chỗ ở thuộc nhóm mở lịch quanh năm (≥360 ngày).
  4. Tỷ lệ phần trăm chỗ ở nằm ở khoảng giữa (0<availability<360).
  5. Giải thích bản chất vì sao không thể suy diễn tỷ lệ lấp đầy phòng hoặc hành vi đặt phòng của khách chỉ dựa trên chỉ số availability_365.
Lời giải

Cách 1: Sử dụng các phương thức thống kê mô tả cơ bản của pandas ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

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

import pandas as pd


def analyze_bimodal_distribution(avail: pd.Series) -> dict:
    s = avail.dropna()
    n = len(s)

    # 1. Tính toán số đo trung tâm
    trung_binh = float(s.mean())
    trung_vi = float(s.median())

    # 2. Phân tách ba phân khúc hành vi
    so_khoa = int((s == 0).sum())
    so_mo_quanh_nam = int((s >= 360).sum())
    so_giua = int(((s > 0) & (s < 360)).sum())

    return {
        "mean": round(trung_binh, 2),
        "median": round(trung_vi, 2),
        "pct_khoa": round(so_khoa / n * 100, 2),
        "pct_mo_quanh_nam": round(so_mo_quanh_nam / n * 100, 2),
        "pct_giua": round(so_giua / n * 100, 2),
        "so_khoa": so_khoa,
        "so_mo_quanh_nam": so_mo_quanh_nam,
        "so_giua": so_giua,
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
kq_bimodal_c1 = analyze_bimodal_distribution(DEMO_DS["availability_365"])
display(kq_bimodal_c1)

# Kết thúc lời giải

Giải thích cách 1 ​

Chia các giá trị theo điều kiện mở lịch đã nêu rồi đếm từng nhóm. Giữ số ô thiếu riêng để người đọc biết phần dữ liệu chưa vào phép tính. Những khoảng chọn để tóm tắt cần được ghi rõ, không được đổi ngưỡng cho phù hợp một câu chuyện có sẵn.

Kết quả cách 1 ​

TrườngKết quả
mean180.57
median191
pct_khoa11.67
pct_mo_quanh_nam21.67
pct_giua66.67
so_khoa7
so_mo_quanh_nam13
so_giua40

Cách 2: Sử dụng phân khoảng pd.cut để tổng hợp tần suất nhóm ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

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

import pandas as pd


def analyze_bimodal_distribution(avail: pd.Series) -> dict:
    s = avail.dropna()
    n = len(s)

    # Phân nhóm bằng các mốc biên xác định
    nhom = pd.cut(s, bins=[-1, 0, 359, 365], labels=["khoa", "giua", "mo_quanh_nam"])
    dem = nhom.value_counts()

    return {
        "mean": round(float(s.mean()), 2),
        "median": round(float(s.median()), 2),
        "pct_khoa": round(dem["khoa"] / n * 100, 2),
        "pct_mo_quanh_nam": round(dem["mo_quanh_nam"] / n * 100, 2),
        "pct_giua": round(dem["giua"] / n * 100, 2),
        "so_khoa": int(dem["khoa"]),
        "so_mo_quanh_nam": int(dem["mo_quanh_nam"]),
        "so_giua": int(dem["giua"]),
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
kq_bimodal_c2 = analyze_bimodal_distribution(DEMO_DS["availability_365"])
display(
    f"Cách 2: Tỷ lệ hai cực = {kq_bimodal_c2['pct_khoa']}% khóa lịch và {kq_bimodal_c2['pct_mo_quanh_nam']}% mở quanh năm"
)

# Kết thúc lời giải

Giải thích cách 2 ​

Các mặt nạ theo cột giúp kiểm lại từng cận dưới và cận trên. Báo số lượng và tỷ trọng với cùng mẫu số thay vì chỉ gọi một nhóm là áp đảo. Lịch đóng có thể do đã đặt, chủ nhà khóa ngày hoặc thiếu cập nhật, nên không đồng nhất với công suất phòng.

Kết quả cách 2 ​

Kết quả
Cách 2: Tỷ lệ hai cực = 11.67% khóa lịch và 21.67% mở quanh năm

So sánh cách dùng ​

Cách 1 mở từng điều kiện chia nhóm và giữ số ô thiếu riêng. Cách 2 trình bày các nhóm bằng mặt nạ cột để đối chiếu số lượng với tỷ trọng. Khi chọn cách làm, cần giữ nguyên các cận và mẫu số, không đổi ngưỡng giữa hai lời giải.


Bài tập 8: Nhận diện biểu đồ gây hiểu sai và đo lường hệ số phóng đại thị giác ​

Bài tập 8: Đo lường sự sai lệch khi cắt ngắn trục tung

Một thủ thuật trình bày trực quan thường bị lạm dụng trong các báo cáo thiếu trung thực là cắt ngắn trục tung (truncated vertical axis). Bằng cách thiết lập giới hạn dưới của trục y sát với giá trị nhỏ nhất của dữ liệu thay vì bắt đầu từ 0, chênh lệch thực tế nhỏ sẽ bị khuếch đại lên gấp nhiều lần, khiến mắt người đọc có cảm giác về một khoảng cách khổng lồ.

Hãy viết hàm visual_distortion_factor(data: pd.Series, y_min: float, y_max: float) -> dict tính toán: Đặt R1 là tỷ số giá trị và R2 là tỷ số chiều cao hiển thị.

  1. Tỷ số giá trị thực tế giữa nhóm cao nhất và nhóm thấp nhất:
R1=vmaxvmin
  1. Chiều cao thị giác hiển thị của cột trên biểu đồ:
h=v−ymin
  1. Tỷ số chiều cao trực quan trên biểu đồ:
R2=vmax−yminvmin−ymin
  1. Hệ số phóng đại sai lệch thị giác (Visual Distortion Factor):
D=R2R1
  1. Thử nghiệm với chuỗi tỷ lệ ba quận Santiago (65%), Providencia (75%), Las Condes (80%) khi bị cắt ngắn với ymin=60% và ymax=85%.
Lời giải

Cách 1: Tính toán tuần tự từ giá trị cực đại và cực tiểu của Series ​

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

from IPython.display import display

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

import pandas as pd


def visual_distortion_factor(data: pd.Series, y_min: float, y_max: float) -> dict:
    v_min = float(data.min())
    v_max = float(data.max())

    # 1. Tính tỷ số thực tế giữa giá trị cao nhất và thấp nhất
    r_thuc = v_max / v_min

    # 2. Tính chiều cao trực quan của hai cột trên khung vẽ
    h_min = v_min - y_min
    h_max = v_max - y_min

    # 3. Tính tỷ số thị giác và hệ số phóng đại
    r_thi_giac = h_max / h_min
    he_so = r_thi_giac / r_thuc

    return {
        "v_min": v_min,
        "v_max": v_max,
        "r_thuc": round(r_thuc, 3),
        "r_thi_giac": round(r_thi_giac, 3),
        "he_so": round(he_so, 3),
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
dulieu_sosanh = pd.Series(
    [65.0, 75.0, 80.0], index=["Santiago", "Providencia", "Las Condes"]
)
kq_dist_c1 = visual_distortion_factor(dulieu_sosanh, y_min=60.0, y_max=85.0)
display(kq_dist_c1)

# Kết thúc lời giải

Giải thích cách 1 ​

Tính phần trục còn lại sau khi cắt và so với toàn thang đo. Một chênh lệch số học giữ nguyên có thể chiếm tỷ lệ chiều cao lớn hơn trên hình bị cắt. Cần nêu cả giới hạn trục và giá trị dữ liệu để người đọc đối chiếu được.

Kết quả cách 1 ​

TrườngKết quả
v_min65
v_max80
r_thuc1.231
r_thi_giac4
he_so3.25
Biểu đồ cột bị cắt ngắn trục tung gây hiểu sai
Biểu đồ cột bị cắt ngắn trục tung gây hiểu sai

Cách 2: Đóng gói công thức vector hóa an toàn kiểm tra điều kiện biên ​

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

from IPython.display import display

import pandas as pd

dulieu_sosanh = pd.Series(
    [65.0, 75.0, 80.0], index=["Santiago", "Providencia", "Las Condes"]
)

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

import pandas as pd


def visual_distortion_factor(data: pd.Series, y_min: float, y_max: float) -> dict:
    vals = data.to_numpy()
    v_min, v_max = float(vals.min()), float(vals.max())

    # Kiểm tra tính hợp lệ của biên cắt trục tung
    if y_min >= v_min:
        raise ValueError(
            "Giới hạn dưới y_min phải nhỏ hơn giá trị nhỏ nhất của dữ liệu."
        )

    r_thuc = v_max / v_min
    r_thi_giac = (v_max - y_min) / (v_min - y_min)
    he_so = r_thi_giac / r_thuc

    return {
        "v_min": v_min,
        "v_max": v_max,
        "r_thuc": round(r_thuc, 3),
        "r_thi_giac": round(r_thi_giac, 3),
        "he_so": round(he_so, 3),
    }


# Gọi thực nghiệm hàm với dữ liệu mẫu:
kq_dist_c2 = visual_distortion_factor(dulieu_sosanh, y_min=60.0, y_max=85.0)
display(f"Cách 2: Hệ số phóng đại thị giác = {kq_dist_c2['he_so']} lần")

# Kết thúc lời giải

Giải thích cách 2 ​

Đóng phép tính vào hàm giúp thử các mốc cắt khác nhau mà giữ dữ liệu. Kiểm giới hạn trục hợp lệ trước khi chia. Hệ số phóng đại hình ảnh mô tả cách trình bày, không phải mức tăng tương đối thật giữa hai nhóm.

Kết quả cách 2 ​

Kết quả
Cách 2: Hệ số phóng đại thị giác = 3.25 lần
Biểu đồ cột bị cắt ngắn trục tung gây hiểu sai
Biểu đồ cột bị cắt ngắn trục tung gây hiểu sai

So sánh cách dùng ​

Hãy chọn Cách 1 khi muốn minh họa từng bước cơ chế bóp méo thị giác do cắt trục tung trên một ví dụ cụ thể để giảng giải. Cách 2 phù hợp khi cần xây dựng hàm kiểm định tự động có kiểm tra điều kiện biên cho nhiều bộ số liệu.


Bài tập 9: Sửa biểu đồ cột thành biểu đồ trung thực có gốc tọa độ chuẩn ​

Bài tập 9: Vẽ lại biểu đồ trung thực

Để khôi phục tính trung thực khoa học của số liệu, biểu đồ cột so sánh giữa các quận phải được vẽ lại với trục tung xuất phát từ đúng gốc tọa độ 0. Tiêu đề biểu đồ cũng cần được viết lại bằng văn phong khách quan, trung thực, phản ánh đúng tương quan giữa các địa bàn mà không dùng các từ ngữ giật gân, định kiến.

Hãy viết hàm plot_honest_bars(pct: pd.Series, path: str) -> matplotlib.axes.Axes thực hiện:

  1. Nhận Series tỷ lệ % pct ( là tên quận) và đường dẫn tệp path.
  2. Khởi tạo khung hình fig, ax = plt.subplots(figsize=(6, 3.5)).
  3. Vẽ biểu đồ cột đứng ax.bar cho từng quận theo đúng thứ tự của pct.
  4. Bắt buộc thiết lập trục tung bắt đầu từ gốc 0 (ax.set_ylim(bottom=0)).
  5. Đặt tiêu đề phản ánh đúng mức độ chênh lệch thực tế (độ dài tối thiểu 15 ký tự).
  6. Lưu tệp hình ảnh vào path và trả về đối tượng ax.
  7. Thảo luận các trường hợp ngoại lệ hiếm hoi được phép không bắt đầu trục từ 0.
Lời giải

Cách 1: Thiết lập trục tung bắt đầu từ 0 và tiêu đề khách quan ​

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

from IPython.display import display

import pandas as pd

dulieu_sosanh = pd.Series(
    [65.0, 75.0, 80.0], index=["Santiago", "Providencia", "Las Condes"]
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt


def plot_honest_bars(pct: pd.Series, path: str):
    # 1. Khởi tạo khung hình
    fig, ax = plt.subplots(figsize=(6, 3.5))

    # 2. Vẽ các cột đứng
    thanh = ax.bar(pct.index, pct.values, color="#388e3c", width=0.55)

    # 3. Gắn nhãn giá trị trên đầu cột
    ax.bar_label(thanh, fmt="%.0f%%", padding=3, fontsize=10)

    # 4. Trục tung bắt buộc bắt đầu từ 0
    ax.set_ylim(bottom=0, top=100)
    ax.set_ylabel("Tỷ lệ nguyên căn (%)")

    # 5. Tiêu đề phản ánh tương quan khách quan
    ax.set_title(
        "Trục tung xuất phát từ 0: Phản ánh trung thực tương quan tỷ lệ", pad=12
    )

    # 6. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_honest_c1 = plot_honest_bars(dulieu_sosanh, "figures/honest_bars_c1.png")
display(
    f"Đã lưu biểu đồ trung thực: Giới hạn trục y = {ax_honest_c1.get_ylim()}, số cột = {len(ax_honest_c1.patches)}"
)

# Kết thúc lời giải

Giải thích cách 1 ​

Vẽ lại cùng giá trị và cùng thứ tự nhóm trên trục bắt đầu từ 0. Giữ nhãn phần trăm hoặc đơn vị ban đầu để việc so sánh không đổi đại lượng. Hình sửa cần cho thấy chênh lệch thật mà không thêm kết luận về nguyên nhân.

Kết quả cách 1 ​

Kết quả
Đã lưu biểu đồ trung thực: Giới hạn trục y = (np.float64(0.0), np.float64(100.0)), số cột = 3
Biểu đồ cột trung thực có trục tung xuất phát từ gốc 0
Biểu đồ cột trung thực có trục tung xuất phát từ gốc 0

Cách 2: Tùy biến giới hạn trên trục tung theo tỷ lệ 100% kèm nhãn số ​

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

from IPython.display import display

import pandas as pd

dulieu_sosanh = pd.Series(
    [65.0, 75.0, 80.0], index=["Santiago", "Providencia", "Las Condes"]
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import matplotlib.pyplot as plt


def plot_honest_bars(pct: pd.Series, path: str):
    fig, ax = plt.subplots(figsize=(6, 3.5))

    cot = ax.bar(
        pct.index,
        pct.values,
        color="#2e7d32",
        width=0.5,
        edgecolor="#1b5e20",
        linewidth=0.8,
    )
    ax.bar_label(cot, fmt="%.1f%%", padding=4, weight="bold")

    # Thiết lập thang đo đầy đủ 0-100% của đại lượng tỷ lệ
    ax.set_ylim(0, 105)
    ax.set_ylabel("Tỷ lệ phòng nguyên căn (%)")
    ax.set_title(
        "Tỷ lệ phòng nguyên căn giữa các quận có sự tương đồng đáng kể", pad=12
    )

    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


# Gọi thực nghiệm hàm với dữ liệu mẫu:
ax_honest_c2 = plot_honest_bars(dulieu_sosanh, "figures/honest_bars_c2.png")
display(f"Cách 2: Đã lưu biểu đồ với tiêu đề: '{ax_honest_c2.get_title()}'")

# Kết thúc lời giải

Giải thích cách 2 ​

Tạo Figure mới rồi đặt giới hạn, nhãn và lưu tệp trong hàm giúp tránh phụ thuộc hình đã vẽ trước. So sánh chiều cao cột với Series đầu vào bằng mã. Không chỉ kiểm rằng có ảnh được xuất ra, vì một ảnh lưu thành công vẫn có thể dùng sai dữ liệu hoặc sai thang đo.

Kết quả cách 2 ​

Kết quả
Cách 2: Đã lưu biểu đồ với tiêu đề: 'Tỷ lệ phòng nguyên căn giữa các quận có sự tương đồng đáng kể'
Biểu đồ cột trung thực có trục tung xuất phát từ gốc 0
Biểu đồ cột trung thực có trục tung xuất phát từ gốc 0

So sánh cách dùng ​

Bạn nên chọn Cách 1 khi cần sửa nhanh biểu đồ thanh về gốc 0 để loại bỏ ấn tượng sai lệch cho người xem. Cách 2 phù hợp khi đại lượng là tỷ lệ phần trăm cần thang chuẩn 100% và đòi hỏi nhãn số chi tiết để kiểm chứng dữ liệu.


Bài tập 10: Tự động hóa kiểm tra cấu trúc biểu đồ bằng hệ thống khẳng định ​

Bài tập 10: Kiểm định chất lượng biểu đồ tự động

Trong quy trình kỹ thuật dữ liệu tự động (CI/CD visual pipeline), ta không thể dùng mắt người để duyệt qua hàng trăm hình ảnh được sinh ra mỗi ngày. Cần xây dựng một hàm kiểm định chất lượng biểu đồ bằng mã nguồn (visual quality assurance) dựa trên các thuộc tính của đối tượng matplotlib.axes.Axes và hệ thống tệp lưu trữ.

Hãy viết hàm validate_chart_structure(ax: matplotlib.axes.Axes, min_title_len: int = 15, require_ylabel: bool = False, require_xlabel: bool = False, require_zero_x: bool = False, require_zero_y: bool = False, min_patches: int = 0, min_lines: int = 0, saved_path: str = None) -> dict thực hiện:

  1. Kiểm tra tiêu đề tồn tại và có độ dài tối thiểu min_title_len.
  2. Kiểm tra nhãn trục tung (ylabel) và nhãn trục hoành (xlabel) không được rỗng nếu có yêu cầu.
  3. Kiểm tra gốc tọa độ trục hoành ax.get_xlim()[0] == 0 (đối với biểu đồ thanh ngang) hoặc trục tung ax.get_ylim()[0] == 0 (đối với biểu đồ cột đứng).
  4. Kiểm tra số lượng đối tượng hình học: Số lượng thanh len(ax.patches) >= min_patches và số lượng đường len(ax.lines) >= min_lines.
  5. Kiểm tra tệp hình ảnh tại đường dẫn saved_path đã được tạo thành công trên đĩa và có dung lượng lớn hơn 0 byte.
  6. Trả về từ điển trạng thái chi tiết của từng tiêu chí và cờ tổng hợp all_passed: bool.
Lời giải

Cách 1: Kiểm tra tuần tự từng tiêu chí bằng toán tử logic ​

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

from IPython.display import display

import pandas as pd

import matplotlib.pyplot as plt

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)


def share_entire(ds: pd.DataFrame, min_n: int = 300) -> pd.DataFrame:
    # 1. Gom nhóm và tính toán độc lập số lượng và tỷ lệ nguyên căn
    tk = ds.groupby("neighbourhood").agg(
        n=("id", "size"),
        pct=("room_type", lambda cot: (cot == "Entire home/apt").mean()),
    )

    # 2. Lọc theo ngưỡng số lượng tối thiểu và sắp xếp tăng dần theo tỷ lệ
    tk_loc = tk.loc[tk["n"] >= min_n].sort_values("pct", ascending=True)
    return tk_loc


tk_nguyen_can_c1 = share_entire(DEMO_DS, min_n=5)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)


def plot_share_barh(tk: pd.DataFrame, path: str):
    # 1. Khởi tạo khung hình
    fig, ax = plt.subplots(figsize=(7, 4))

    # 2. Vẽ thanh ngang với độ dài thang đo phần trăm
    thanh = ax.barh(tk.index, tk["pct"] * 100, color="#2b5c8f", height=0.65)

    # 3. Gắn nhãn giá trị trực tiếp trên từng thanh
    ax.bar_label(thanh, fmt="%.0f%%", padding=4, fontsize=10)

    # 4. Bắt buộc trục hoành xuất phát từ 0 và mở rộng khoảng đệm bên phải
    ax.set_xlim(left=0, right=115)
    ax.set_xlabel("Tỷ lệ phòng nguyên căn (%)")

    # 5. Tiêu đề phản ánh thông điệp phân tích
    ax.set_title("Tỷ lệ phòng nguyên căn tại các quận có từ 5 chỗ ở trở lên", pad=12)

    # 6. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


ax_barh_c1 = plot_share_barh(tk_nguyen_can_c1, "figures/share-reference.png")

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

from pathlib import Path
import matplotlib.axes


def validate_chart_structure(
    ax: matplotlib.axes.Axes,
    min_title_len: int = 15,
    require_ylabel: bool = False,
    require_xlabel: bool = False,
    require_zero_x: bool = False,
    require_zero_y: bool = False,
    min_patches: int = 0,
    min_lines: int = 0,
    saved_path: str = None,
) -> dict:
    # 1. Kiểm tra độ dài tiêu đề
    tieu_de = ax.get_title().strip()
    dat_tieu_de = len(tieu_de) >= min_title_len

    # 2. Kiểm tra nhãn các trục tọa độ
    dat_nhan_y = not require_ylabel or bool(ax.get_ylabel().strip())
    dat_nhan_x = not require_xlabel or bool(ax.get_xlabel().strip())

    # 3. Kiểm tra gốc tọa độ bắt đầu từ 0
    dat_goc_x = not require_zero_x or (ax.get_xlim()[0] == 0)
    dat_goc_y = not require_zero_y or (ax.get_ylim()[0] == 0)

    # 4. Kiểm tra số lượng đối tượng hình học trên đồ thị
    dat_patches = len(ax.patches) >= min_patches
    dat_lines = len(ax.lines) >= min_lines

    # 5. Kiểm tra tệp hình ảnh đã được ghi xuống đĩa
    dat_tep = True
    if saved_path:
        tep = Path(saved_path)
        dat_tep = tep.exists() and tep.stat().st_size > 0

    tat_ca_dat = all(
        [
            dat_tieu_de,
            dat_nhan_y,
            dat_nhan_x,
            dat_goc_x,
            dat_goc_y,
            dat_patches,
            dat_lines,
            dat_tep,
        ]
    )

    return {
        "has_title": dat_tieu_de,
        "has_ylabel": dat_nhan_y,
        "has_xlabel": dat_nhan_x,
        "has_zero_x": dat_goc_x,
        "has_zero_y": dat_goc_y,
        "has_patches": dat_patches,
        "has_lines": dat_lines,
        "has_file": dat_tep,
        "all_passed": tat_ca_dat,
    }


# Gọi thực nghiệm hàm kiểm tra biểu đồ thanh ngang Bài 5:
kiem_tra_c1 = validate_chart_structure(
    ax_barh_c1,
    min_title_len=15,
    require_xlabel=True,
    require_zero_x=True,
    min_patches=5,
    saved_path="figures/pct_nguyen_can_c1.png",
)
display(kiem_tra_c1)

# Kết thúc lời giải

Giải thích cách 1 ​

Đọc lại cấu trúc Axes để kiểm tiêu đề, nhãn, số đường hoặc thanh và giới hạn trục. Các assert kiểm những điều quan sát được trong hình, như số điểm và giá trị đã vẽ. Chúng không đánh giá thay người đọc về sự đúng mức của thông điệp.

Kết quả cách 1 ​

TrườngKết quả
has_titleTrue
has_ylabelTrue
has_xlabelTrue
has_zero_xTrue
has_zero_yTrue
has_patchesTrue
has_linesTrue
has_fileTrue

Cách 2: Xây dựng bộ quy tắc kiểm định mở rộng theo dạng từ điển kiểm tra ​

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

from IPython.display import display

import pandas as pd

import matplotlib.pyplot as plt

dulieu_sosanh = pd.Series(
    [65.0, 75.0, 80.0], index=["Santiago", "Providencia", "Las Condes"]
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)


def plot_honest_bars(pct: pd.Series, path: str):
    # 1. Khởi tạo khung hình
    fig, ax = plt.subplots(figsize=(6, 3.5))

    # 2. Vẽ các cột đứng
    thanh = ax.bar(pct.index, pct.values, color="#388e3c", width=0.55)

    # 3. Gắn nhãn giá trị trên đầu cột
    ax.bar_label(thanh, fmt="%.0f%%", padding=3, fontsize=10)

    # 4. Trục tung bắt buộc bắt đầu từ 0
    ax.set_ylim(bottom=0, top=100)
    ax.set_ylabel("Tỷ lệ nguyên căn (%)")

    # 5. Tiêu đề phản ánh tương quan khách quan
    ax.set_title(
        "Trục tung xuất phát từ 0: Phản ánh trung thực tương quan tỷ lệ", pad=12
    )

    # 6. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return ax


ax_honest_c1 = plot_honest_bars(dulieu_sosanh, "figures/honest-reference.png")

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

from pathlib import Path
import matplotlib.axes


def validate_chart_structure(
    ax: matplotlib.axes.Axes,
    min_title_len: int = 15,
    require_ylabel: bool = False,
    require_xlabel: bool = False,
    require_zero_x: bool = False,
    require_zero_y: bool = False,
    min_patches: int = 0,
    min_lines: int = 0,
    saved_path: str = None,
) -> dict:
    ket_qua = {
        "has_title": len(ax.get_title().strip()) >= min_title_len,
        "has_ylabel": not require_ylabel or bool(ax.get_ylabel().strip()),
        "has_xlabel": not require_xlabel or bool(ax.get_xlabel().strip()),
        "has_zero_x": not require_zero_x or (round(ax.get_xlim()[0], 5) == 0.0),
        "has_zero_y": not require_zero_y or (round(ax.get_ylim()[0], 5) == 0.0),
        "has_patches": len(ax.patches) >= min_patches,
        "has_lines": len(ax.lines) >= min_lines,
        "has_file": (
            bool(
                saved_path
                and Path(saved_path).is_file()
                and Path(saved_path).stat().st_size > 0
            )
            if saved_path
            else True
        ),
    }
    ket_qua["all_passed"] = all(ket_qua.values())
    return ket_qua


# Gọi thực nghiệm hàm kiểm tra biểu đồ cột trung thực Bài 9:
kiem_tra_c2 = validate_chart_structure(
    ax_honest_c1,
    min_title_len=15,
    require_ylabel=True,
    require_zero_y=True,
    min_patches=3,
    saved_path="figures/honest_bars_c1.png",
)
display(f"Cách 2: Trạng thái kiểm định toàn diện = {kiem_tra_c2['all_passed']}")

# Kết thúc lời giải

Giải thích cách 2 ​

Đóng các điều kiện thành hàm kiểm giúp dùng lại cho nhiều hình. Điều kiện cần phù hợp từng loại biểu đồ, vì trục log hoặc biểu đồ đường có quy ước khác biểu đồ thanh. Kiểm tự động cấu trúc phải đi cùng xem hình để phát hiện chữ bị cắt, màu khó phân biệt hoặc nguồn ghi sai.

Kết quả cách 2 ​

Kết quả
Cách 2: Trạng thái kiểm định toàn diện = True

So sánh cách dùng ​

Hãy chọn Cách 1 khi viết kiểm thử đơn vị cho một đồ thị cụ thể để xác minh nhanh cấu trúc của các trục. Cách 2 cần thiết khi muốn xây dựng bộ quy tắc kiểm định tái sử dụng nhằm tự động hóa đánh giá chất lượng nhiều biểu đồ.


Bài tập 11: So sánh phân phối giữa các nhóm trên biểu đồ ghép cùng thang đo ​

Bài tập 11: Dựng biểu đồ ghép cùng thang đo

Khi so sánh phân phối của một đại lượng (như số ngày mở lịch availability_365) giữa hai địa bàn khác nhau (Ví dụ: Quận Providencia và quận Santiago), sai lầm phổ biến là vẽ hai biểu đồ độc lập với thang đo trục tung tự động co giãn riêng biệt. Khi đó, một cột cao ở biểu đồ quận nhỏ có thể chỉ chứa 5 chỗ ở nhưng lại trông đồ sộ ngang với cột chứa 20 chỗ ở của quận lớn, gây hiểu sai lệch hoàn toàn về quy mô. Cần sử dụng kỹ thuật biểu đồ con ghép đôi với tham số chia sẻ trục tung (sharey=True).

Hãy viết hàm plot_comparative_groups(ds: pd.DataFrame, quan_a: str, quan_b: str, path: str, bins: int = 20) -> tuple thực hiện:

  1. Trích xuất Series availability_365 không chứa NaN của quận quan_a và quận quan_b.
  2. Khởi tạo khung hình gồm hai biểu đồ con nằm cạnh nhau với trục tung dùng chung: fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5), sharey=True).
  3. Vẽ histogram cho quan_a trên ax1 và cho quan_b trên ax2 với cùng số lượng khoảng chia bins.
  4. Đặt tiêu đề riêng cho từng biểu đồ con ghi rõ tên quận và số lượng quan sát N.
  5. Đặt tiêu đề chung cho toàn bộ khung hình bằng fig.suptitle(...).
  6. Lưu tệp hình ảnh vào path và trả về bộ (fig, (ax1, ax2)).
Lời giải

Cách 1: Sử dụng subplots với tham số sharey và vẽ độc lập từng trục ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import pandas as pd
import matplotlib.pyplot as plt


def plot_comparative_groups(
    ds: pd.DataFrame, quan_a: str, quan_b: str, path: str, bins: int = 20
) -> tuple:
    # 1. Trích xuất dữ liệu của hai địa bàn khảo sát
    avail_a = ds.loc[ds["neighbourhood"] == quan_a, "availability_365"].dropna()
    avail_b = ds.loc[ds["neighbourhood"] == quan_b, "availability_365"].dropna()

    # 2. Khởi tạo biểu đồ ghép đôi chia sẻ trục tung
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5), sharey=True)

    # 3. Vẽ phân phối cho quận A
    ax1.hist(avail_a, bins=bins, color="#1976d2", edgecolor="white", linewidth=0.5)
    ax1.set_title(f"Quận {quan_a} (N = {len(avail_a)})", fontsize=11)
    ax1.set_xlabel("Số ngày mở lịch (ngày)")
    ax1.set_ylabel("Số lượng chỗ ở")

    # 4. Vẽ phân phối cho quận B với cùng thang đo trục tung
    ax2.hist(avail_b, bins=bins, color="#f57c00", edgecolor="white", linewidth=0.5)
    ax2.set_title(f"Quận {quan_b} (N = {len(avail_b)})", fontsize=11)
    ax2.set_xlabel("Số ngày mở lịch (ngày)")

    # 5. Tiêu đề tổng quát cho toàn bộ khung so sánh
    fig.suptitle(
        "So sánh phân phối số ngày mở lịch trên cùng thang đo trục tung",
        y=1.02,
        fontsize=12,
    )

    # 6. Lưu tệp hình ảnh
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return fig, (ax1, ax2)


# Gọi thực nghiệm hàm với dữ liệu mẫu:
fig_comp_c1, (ax_pro_c1, ax_san_c1) = plot_comparative_groups(
    DEMO_DS, "Providencia", "Santiago", "figures/comparative_groups_c1.png", bins=20
)
display(f"Đã lưu biểu đồ ghép đôi: Cùng giới hạn trục y = {ax_pro_c1.get_ylim()}")

# Kết thúc lời giải

Giải thích cách 1 ​

Đưa các nhóm lên những khung hình có cùng thang đo để chiều cao và độ dài so sánh được. Nếu từng khung tự chọn giới hạn, một biến động nhỏ có thể trông lớn như biến động ở nhóm khác. Giữ tên nhóm và đơn vị ở vị trí dễ đọc.

Kết quả cách 1 ​

Kết quả
Đã lưu biểu đồ ghép đôi: Cùng giới hạn trục y = (np.float64(0.0), np.float64(4.2))
So sánh phân phối số ngày mở lịch giữa hai quận trên cùng thang đo
So sánh phân phối số ngày mở lịch giữa hai quận trên cùng thang đo

Cách 2: Tự động hóa tính toán khoảng chia dùng chung và đồng bộ đường gióng ​

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

from IPython.display import display

import pandas as pd

_QUAN = (
    ["Santiago"] * 20
    + ["Providencia"] * 12
    + ["Las Condes"] * 10
    + ["Ñuñoa"] * 8
    + ["Lo Barnechea"] * 6
    + ["Vitacura"] * 4
)

_CHU_KY_PHONG_RIENG = {
    "Santiago": 3,
    "Providencia": 4,
    "Las Condes": 5,
    "Ñuñoa": 2,
    "Lo Barnechea": 0,
    "Vitacura": 4,
}

DEMO_DS = pd.DataFrame(
    {
        "id": list(range(101, 161)),
        "neighbourhood": _QUAN,
        "room_type": [
            (
                "Private room"
                if _CHU_KY_PHONG_RIENG[q] and k % _CHU_KY_PHONG_RIENG[q] == 1
                else "Entire home/apt"
            )
            for k, q in enumerate(_QUAN)
        ],
        "availability_365": [
            0 if k % 9 == 0 else 365 if k % 4 == 0 else (k * 37) % 300 + 20
            for k in range(60)
        ],
    }
)

from pathlib import Path

Path("figures").mkdir(parents=True, exist_ok=True)

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

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt


def plot_comparative_groups(
    ds: pd.DataFrame, quan_a: str, quan_b: str, path: str, bins: int = 20
) -> tuple:
    s_a = ds.loc[ds["neighbourhood"] == quan_a, "availability_365"].dropna()
    s_b = ds.loc[ds["neighbourhood"] == quan_b, "availability_365"].dropna()

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 3.5), sharey=True)
    lưới_chia = np.linspace(0, 365, bins + 1)

    ax1.hist(s_a, bins=lưới_chia, color="#1565c0", edgecolor="#ffffff", linewidth=0.6)
    ax1.set_title(f"Địa bàn {quan_a} (N = {len(s_a)})", fontsize=11)
    ax1.set_xlabel("Số ngày mở lịch (ngày)")
    ax1.set_ylabel("Số lượng chỗ ở")

    ax2.hist(s_b, bins=lưới_chia, color="#e65100", edgecolor="#ffffff", linewidth=0.6)
    ax2.set_title(f"Địa bàn {quan_b} (N = {len(s_b)})", fontsize=11)
    ax2.set_xlabel("Số ngày mở lịch (ngày)")

    fig.suptitle(
        "Phân tích so sánh phân phối số ngày mở lịch giữa hai quận", y=1.02, fontsize=12
    )
    fig.savefig(path, dpi=150, bbox_inches="tight")
    return fig, (ax1, ax2)


# Gọi thực nghiệm hàm với dữ liệu mẫu:
fig_comp_c2, (ax_pro_c2, ax_san_c2) = plot_comparative_groups(
    DEMO_DS, "Providencia", "Santiago", "figures/comparative_groups_c2.png", bins=20
)
display(f"Cách 2: Giới hạn trục y dùng chung = {ax_pro_c2.get_ylim()}")

# Kết thúc lời giải

Giải thích cách 2 ​

Dùng subplots và các tùy chọn chia sẻ trục giúp thống nhất giới hạn một cách tường minh. Mã vẫn cần kiểm dữ liệu của từng nhóm và thứ tự kỳ. Khi đơn vị hai đại lượng khác nhau, không ép chúng vào cùng thang rồi diễn giải như cùng độ lớn.

Kết quả cách 2 ​

Kết quả
Cách 2: Giới hạn trục y dùng chung = (np.float64(0.0), np.float64(4.2))
So sánh phân phối số ngày mở lịch giữa hai quận trên cùng thang đo
So sánh phân phối số ngày mở lịch giữa hai quận trên cùng thang đo

So sánh cách dùng ​

Cả hai cách dùng sharey=True để giữ cùng thang tần suất. Cách 2 còn dùng chung lưới khoảng từ 0 đến 365 ngày, giúp một khoảng ngày mang cùng ý nghĩa ở cả hai hình. Chọn cách này khi cần đối chiếu trực tiếp hình dạng hai phân phối.


4. Tổng kết quy tắc thực hành trực quan hóa dữ liệu ​

Nguyên tắc kỹ thuậtQuy chuẩn chuẩn mựcLỗi thường gặp cần tránhHậu quả nếu vi phạm
Gốc tọa độ biểu đồ thanh/cộtBắt buộc xuất phát từ 0 (xlim[0] == 0 hoặc ylim[0] == 0)Cắt ngắn trục tung để làm nổi bật chênh lệchPhóng đại sai lệch thị giác gấp nhiều lần
Kỳ thời gian chưa trọn vẹnLoại bỏ tháng hoặc quý cuối cùng chưa kết thúcGiữ nguyên kỳ cụt trong chuỗi thời gianGây hiểu lầm về sự sụt giảm đột ngột
Nhãn đo lường và đơn vịGhi rõ đơn vị tính trên các trục tọa độChỉ ghi tên biến viết tắt không có đơn vịNgười xem không hiểu thang đo số liệu
Phân phối hai đỉnhDùng biểu đồ tần suất phân tách các phân khúcChỉ báo cáo duy nhất giá trị trung bình cộngGiá trị trung bình rơi vào vùng không có dữ liệu
So sánh nhiều nhómSử dụng sharey=True hoặc đồng bộ thang đoĐể mỗi biểu đồ con tự co giãn trục độc lậpĐộc giả nhận thức sai lệch về quy mô mẫu
Quy trình xuất bản tự độngKiểm tra cấu trúc hình ảnh bằng câu lệnh khẳng địnhKiểm tra thủ công bằng mắt thườngBỏ sót lỗi kỹ thuật khi chạy pipeline lớn

Lab 13: Trực quan hóa nâng cao với seaborn, bản đồ địa lý và phản biện biểu đồ ​

Cùng một bảng giá, biểu đồ hộp cho biết mức giá giữa các nhóm, còn bản đồ cho biết phân bố theo quận. Số đánh giá theo năm lại đặt ra một câu hỏi khác: Mức thay đổi phụ thuộc thế nào vào năm được chọn để so sánh? Bảy bài dưới đây lần lượt kiểm tra các cách trình bày này bằng dữ liệu nhỏ có thể tính lại.


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

Các bài tập thực nghiệm trong bài học này đối chiếu trực tiếp với bộ dữ liệu lưu trữ công cộng của Inside Airbnb đối với thành phố Santiago, Chile:

  • Đị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, 2026-06-29/visualisations/reviews.csv và 2026-06-29/visualisations/neighbourhoods.geojson).
  • Đơn vị đo lường: Giá niêm yết được biểu diễn theo CLP/đêm. Giữ số khi tính toán và chọn mức làm tròn khi trình bày.
  • Hệ quy chiếu tọa độ không gian (CRS): Kiểm tra CRS của dữ liệu khi nạp bản đồ thật. Các hình chữ nhật giả lập bên dưới được gán EPSG:3857 để minh họa phép ghép và vẽ.

NOTE

Dữ liệu để thử chương trình: DEMO_DS có 48 dòng chỗ ở ở năm quận. DEMO_QUAN_BAN_DO có sáu tên quận, thêm Pirque không có dòng chỗ ở trong mẫu. DEMO_RV chứa 3.900 đánh giá giả lập trong các năm 2014–2026.

Các hình chữ nhật trong ví dụ là ranh giới giả lập, không phải vị trí hoặc hình dạng các quận Santiago. Các số đếm và mức giá chỉ mô tả mẫu này. Nhãn “chuyên nghiệp” và “cá nhân” trong code là tên quy ước theo ngưỡng số chỗ ở, không xác nhận nghề nghiệp, chất lượng dịch vụ hay hình thức pháp lý của chủ nhà.

Khởi tạo môi trường và dữ liệu dùng chung ​

Toàn bộ các ví dụ và lời giải trong tài liệu sử dụng thư viện pandas, numpy, matplotlib, seaborn, shapely và geopandas:

python
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import seaborn as sns
from shapely.geometry import box
import geopandas as gpd

# Thiết lập giao diện đồ họa chuẩn mực
sns.set_theme(style="whitegrid")

# Bảng chỗ ở mẫu giả lập: 48 chỗ ở tại 5 quận
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
DEMO_DS = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "room_type": [
            "Private room" if k % 4 == 1 else "Entire home/apt" for k in range(48)
        ],
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
        "calculated_host_listings_count": [1 + (k * 5) % 9 for k in range(48)],
    }
)

# Danh sách 6 quận trên bản đồ ranh giới (bao gồm quận Pirque trắng dữ liệu)
DEMO_QUAN_BAN_DO = [
    "Santiago",
    "Providencia",
    "Las Condes",
    "Ñuñoa",
    "Lo Barnechea",
    "Pirque",
]

# Bảng đánh giá mẫu giả lập: Tái hiện diễn biến chuỗi thời gian 2014-2026
_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}
DEMO_RV = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)

print(
    "Khởi tạo môi trường thành công: Chỗ ở =",
    len(DEMO_DS),
    ", Quận bản đồ =",
    len(DEMO_QUAN_BAN_DO),
    ", Đánh giá =",
    len(DEMO_RV),
)

Bảng 13.0: Cấu trúc dữ liệu mẫu giả lập của danh sách chỗ ở (DEMO_DS):

idneighbourhoodroom_typeprice (CLP)calculated_host_listings_count
201SantiagoEntire home/apt33,0001
202SantiagoPrivate room25,9196
203SantiagoEntire home/apt33,8382
204SantiagoEntire home/apt56,7577
205SantiagoEntire home/apt49,6763
206SantiagoEntire home/apt8

Bài 1: So sánh phân phối giá theo nhóm chủ nhà và loại phòng bằng boxplot với hue trên thang logarit ​

Mục tiêu: Khảo sát phân phối giá lưu trú nhiều chiều bằng biểu đồ hộp (boxplot) của seaborn, kết hợp phân tầng màu sắc (hue) để so sánh hai nhóm quy mô chủ nhà theo từng loại phòng, rồi thực hành đọc giá trên thang log.

Dữ liệu đầu vào: Bảng dữ liệu chỗ ở co_gia đã loại bỏ các bản ghi khuyết giá (price.dropna()), gồm các cột:

  • price: Mức giá niêm yết mỗi đêm (số thực dương).
  • room_type: Loại chỗ ở (Entire home/apt, Private room).
  • kieu_host: Nhóm chủ nhà (chuyên nghiệp nếu calculated_host_listings_count từ 5 trở lên, cá nhân nếu calculated_host_listings_count dưới 5).

Thao tác yêu cầu:

  1. Xây dựng hàm plot_price_box(co_gia: pd.DataFrame) nhận bảng dữ liệu và trả về đối tượng Axes.
  2. Tạo khung vẽ mới với kích thước figsize=(8.5, 4).
  3. Sử dụng hàm sns.boxplot với trục hoành là price, trục tung là room_type, và phân tầng màu sắc hue="kieu_host".
  4. Thiết lập thang đo logarit cho trục hoành bằng lệnh ax.set_xscale("log").
  5. Đặt tiêu đề rõ ràng phản ánh thông điệp phân tích (độ dài tối thiểu 15 ký tự), đặt nhãn trục hoành có đơn vị đo rõ ràng.
  6. Bảo đảm chú giải đồ thị do tham số hue sinh ra hiển thị đúng hai nhóm giá trị chuyên nghiệp và cá nhân.
  7. Trả lời câu hỏi phân tích: Ở từng loại phòng, hộp phân phối của nhóm chuyên nghiệp nằm lệch về phía nào? Hai nhóm chủ nhà có tách rời thành hai phân khúc giá riêng biệt hay không? Khi nào thang log hữu ích, và khi nào có thể giữ thang tuyến tính?

Trường hợp biên cần chú ý: Hàm phải hoạt động ổn định trên bảng dữ liệu chỉ có một loại phòng duy nhất hoặc khi một trong hai nhóm chủ nhà không có quan sát trong một phân khúc phòng.

Lời giải

Đọc hộp trước khi đọc màu ​

Hộp trải từ tứ phân vị dưới Q1 đến tứ phân vị trên Q3, đường trong hộp là trung vị. Với whis=1.5 mặc định, hai râu đi tới các quan sát xa nhất còn nằm trong hàng rào tương ứng. Râu không nhất thiết đi tới cực tiểu, cực đại của toàn bộ nhóm. Các quan sát ngoài râu được vẽ riêng.

Khoảng tứ phân vị được tính bằng:

I=Q3−Q1.

Hàng rào dưới là:

L=Q1−1,5I.

Hàng rào trên là:

U=Q3+1,5I.

Thang log thích hợp khi cần xem các giá dương trải trên nhiều mức độ lớn hoặc so sánh theo tỷ lệ. Khoảng từ 10 đến 100 có cùng độ dài hiển thị với khoảng từ 100 đến 1000 trên thang log cơ số 10. Nó không biến mọi phân phối thành đối xứng và không bắt buộc cho mọi boxplot. Bài này dùng thang log để thực hành cách đọc đó.

Trên fixture, trung vị nhóm có từ năm chỗ ở cao hơn ở nguyên căn nhưng thấp hơn ở phòng riêng. Vì vậy không thể áp nhận xét cùng chiều cho cả hai loại phòng. Hai khoảng tứ phân vị có phần chồng lấn, nhưng điều đó không chứng minh nguyên nhân định giá hay quan hệ cạnh tranh giữa chủ nhà.

Cách 1: Sử dụng hàm seaborn.boxplot trực tiếp với giao diện Axes ​

python
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns


def plot_price_box(co_gia: pd.DataFrame) -> plt.Axes:
    """Vẽ boxplot phân phối giá theo loại phòng và nhóm chủ nhà trên thang logarit.

    Tham số:
        co_gia: DataFrame chứa cột 'price', 'room_type' và 'kieu_host'.
    Trả về:
        Đối tượng Axes chứa biểu đồ đã được định dạng chuẩn mực.
    """
    fig, ax = plt.subplots(figsize=(8.5, 4))
    sns.boxplot(
        data=co_gia,
        x="price",
        y="room_type",
        hue="kieu_host",
        palette=["#3182bd", "#de2d26"],
        ax=ax,
    )
    ax.set_xscale("log")
    ax.set_title(
        "Phân phối giá theo loại phòng và nhóm chủ nhà trên thang logarit",
        fontsize=11,
        fontweight="bold",
    )
    ax.set_xlabel("Giá niêm yết mỗi đêm (CLP, thang logarit)")
    ax.set_ylabel("Loại phòng lưu trú")
    return ax


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "room_type": [
            "Private room" if k % 4 == 1 else "Entire home/apt" for k in range(48)
        ],
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
        "calculated_host_listings_count": [1 + (k * 5) % 9 for k in range(48)],
    }
)
co_gia_demo = demo_ds.dropna(subset=["price"]).assign(
    kieu_host=lambda d: (d["calculated_host_listings_count"] >= 5).map(
        {True: "chuyên nghiệp", False: "cá nhân"}
    )
)

ax_kq_c1 = plot_price_box(co_gia_demo)
print("Tiêu đề biểu đồ Cách 1:", ax_kq_c1.get_title())
print("Thang đo trục x:", ax_kq_c1.get_xscale())
print(
    "Các nhóm trong chú giải:",
    [t.get_text() for t in ax_kq_c1.get_legend().get_texts()],
)
plt.close(ax_kq_c1.figure)
Giải thích chi tiết Cách 1 ​

plt.subplots tạo Figure và Axes để vẽ một hình độc lập. sns.boxplot đọc cột price cho trục ngang, room_type cho trục dọc và chia mỗi loại phòng theo hue. Mỗi hộp tóm tắt giá của một cặp loại phòng và nhóm quy ước.

ax.set_xscale("log") đổi cách đặt vị trí trên trục, nên chỉ dùng cho giá dương. Bước dropna trong ví dụ loại giá thiếu nhưng chưa tự loại giá bằng không hoặc âm từ một nguồn khác. Danh sách palette được gắn theo thứ tự hue mà seaborn suy ra. Màu hỗ trợ phân nhóm, còn nhãn chú giải vẫn cần để đọc đúng.

Kết quả thực thi Cách 1 ​
Thuộc tính đọc từ AxesKết quả trên fixture
get_xscale()log
Tiêu đềPhân phối giá theo loại phòng và nhóm chủ nhà trên thang logarit
Độ dài tiêu đề64 ký tự
Thứ tự chú giải["cá nhân", "chuyên nghiệp"] theo thứ tự xuất hiện của fixture
Số nhóm có giá4
Hình 13.1: Phân phối giá niêm yết theo loại phòng và nhóm chủ nhà trên thang logarit
Hình 13.1: Phân phối giá niêm yết theo loại phòng và nhóm chủ nhà trên thang logarit

Cách 2: Kiểm soát thứ tự phân loại tường minh và tùy biến hộp nâng cao ​

python
import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns


def plot_price_box_nang_cao(co_gia: pd.DataFrame) -> plt.Axes:
    """Vẽ boxplot với thứ tự phân loại cố định và định dạng chú giải nâng cao.

    Tham số:
        co_gia: DataFrame chứa cột 'price', 'room_type' và 'kieu_host'.
    Trả về:
        Đối tượng Axes đã tùy biến chuyên sâu.
    """
    fig, ax = plt.subplots(figsize=(8.5, 4))
    thu_tu_phong = ["Entire home/apt", "Private room"]
    thu_tu_host = ["cá nhân", "chuyên nghiệp"]
    sns.boxplot(
        data=co_gia,
        x="price",
        y="room_type",
        hue="kieu_host",
        order=thu_tu_phong,
        hue_order=thu_tu_host,
        palette={"cá nhân": "#2b8cbe", "chuyên nghiệp": "#e6550d"},
        width=0.6,
        showfliers=True,
        ax=ax,
    )
    ax.set_xscale("log")
    ax.set_title(
        "So sánh mức giá phân vị giữa chủ nhà chuyên nghiệp và cá nhân",
        fontsize=11,
        fontweight="bold",
    )
    ax.set_xlabel("Mức giá niêm yết (CLP/đêm, thang đo logarit)")
    ax.set_ylabel("Phân khúc phòng")
    ax.legend(title="Nhóm chủ nhà", loc="lower right", frameon=True)
    return ax


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "room_type": [
            "Private room" if k % 4 == 1 else "Entire home/apt" for k in range(48)
        ],
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
        "calculated_host_listings_count": [1 + (k * 5) % 9 for k in range(48)],
    }
)
co_gia_demo = demo_ds.dropna(subset=["price"]).assign(
    kieu_host=lambda d: (d["calculated_host_listings_count"] >= 5).map(
        {True: "chuyên nghiệp", False: "cá nhân"}
    )
)

ax_kq_c2 = plot_price_box_nang_cao(co_gia_demo)
print("Tiêu đề biểu đồ Cách 2:", ax_kq_c2.get_title())
print("Thang đo trục x Cách 2:", ax_kq_c2.get_xscale())
print(
    "Kiểm tra nhãn chú giải:", [t.get_text() for t in ax_kq_c2.get_legend().get_texts()]
)
plt.close(ax_kq_c2.figure)
Giải thích chi tiết Cách 2 ​

order cố định thứ tự hai loại phòng và hue_order cố định thứ tự hai nhóm. Từ điển palette gắn một màu với từng nhãn thay vì phụ thuộc vị trí đầu tiên trong dữ liệu. Khi thiếu một nhóm, thứ tự danh mục khai báo vẫn giúp so sánh các hình giữa những lần chạy.

width=0.6 chỉnh bề rộng nhóm hộp trên trục phân loại. showfliers=True giữ các điểm ngoài râu để người xem biết chúng tồn tại. Vị trí chú giải và khoảng cách chữ cần được rà lại khi tên nhóm hoặc số nhóm thay đổi. Những tùy chọn này không làm thay đổi số quan sát hoặc trung vị của từng nhóm.

Kết quả thực thi Cách 2 ​
room_typeNhóm quy ướcn giá hợp lệTrung vị (CLP)Q1Q3
Entire home/aptcá nhân1564760.045570.085961.0
Entire home/aptchuyên nghiệp1872637.040123.090056.75
Private roomcá nhân5104271.067651.0114327.0
Private roomchuyên nghiệp651813.037232.070165.0

So sánh hai cách ​

Cách 1 phù hợp để dựng và kiểm tra hình đầu tiên. Cách 2 thêm thứ tự và ánh xạ màu cố định cho việc so sánh nhiều hình. Các số trong bảng là thống kê của 44 giá hợp lệ trong fixture, không phải thống kê thị trường Santiago.


Bài 2: Ghép dữ liệu địa lý không gian với ranh giới quận, thống kê số chỗ ở và xử lý quận khuyết dữ liệu ​

Mục tiêu: Thực hiện phép kết hợp dữ liệu bảng thống kê chỗ ở với bảng ranh giới hình học không gian của các quận (GeoDataFrame), thống kê số lượng chỗ ở thực tế của từng quận, kiểm tra quận không có dòng trong mẫu và giải thích khi nào có thể điền số đếm bằng 0.

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

  • Bảng dữ liệu chỗ ở ds: Chứa cột neighbourhood (tên quận hành chính của từng chỗ ở).
  • Bảng ranh giới địa lý geo: hoặc GeoDataFrame chứa cột neighbourhood với các tên quận duy nhất không trùng lặp (ví dụ 6 quận: Santiago, Providencia, Las Condes, Ñuñoa, Lo Barnechea, Pirque).

Thao tác yêu cầu:

  1. Xây dựng hàm count_by_area(ds: pd.DataFrame, geo: pd.DataFrame) -> dict.
  2. Đếm số lượng chỗ ở theo từng quận từ bảng ds, đặt tên cột số lượng là n.
  3. Thực hiện phép ghép trái geo.merge(..., on="neighbourhood", how="left") để giữ nguyên toàn bộ số dòng và thứ tự dòng ban đầu của bảng ranh giới geo.
  4. Đếm số lượng quận có ranh giới trên bản đồ nhưng hoàn toàn không có chỗ ở nào trong bảng ds (số lượng ô NaN ngay sau phép ghép trái), lưu vào biến so_quan_nan.
  5. Điền giá trị 0 cho cột n bằng phương thức fillna(0) và ép kiểu sang số nguyên int.
  6. Không chỉnh sửa bảng dữ liệu geo đầu vào. Chỗ ở thuộc các quận không có tên trên bản đồ geo sẽ tự động bị loại bỏ khỏi bảng kết quả.
  7. Trả về từ điển có đúng hai khóa:
    • "so_quan_nan": Số nguyên biểu thị số quận trắng dữ liệu.
    • "ban_do": Bảng sau khi ghép và điền 0, chứa toàn bộ các cột ban đầu của geo cộng thêm cột n.

Trường hợp biên cần chú ý: Bảng ds có thể không chứa bất kỳ chỗ ở nào của một số quận trên bản đồ, hoặc ngược lại chứa các chỗ ở có tên quận bị sai chính tả không khớp với geo. Phép ghép trái phải bảo toàn số hàng và thứ tự của geo.

Lời giải

Khi nào điền số đếm bằng không? ​

geo có sáu tên quận, trong khi ds chỉ chứa năm tên. Ghép trái theo tên giữ các dòng ranh giới và thêm số đếm nếu có khóa khớp. Đây là phép ghép theo thuộc tính, không phải phép xác định một điểm nằm trong đa giác nào.

Pirque không có dòng trong fixture nên số đếm sau ghép là . Sau khi kiểm tra tên khóa khớp và xác nhận phạm vi mẫu, ta có thể ghi số quan sát bằng 0. Nếu tên quận sai chính tả hoặc nguồn thu thập chưa bao phủ quận, NaN chưa đủ để kết luận không có chỗ ở. Các tên chỉ có trong ds nhưng không có trong geo không xuất hiện trên bảng kết quả và cần được kiểm tra riêng.

Cách 1: Sử dụng groupby kết hợp phép ghép merge và fillna tuần tự ​

python
import pandas as pd


def count_by_area_buoc(ds: pd.DataFrame, geo: pd.DataFrame) -> dict:
    """Ghép số lượng chỗ ở vào bảng ranh giới địa lý theo cách tiếp cận tuần tự.

    Tham số:
        ds: DataFrame chứa cột 'neighbourhood'.
        geo: DataFrame hoặc GeoDataFrame chứa cột 'neighbourhood'.
    Trả về:
        Từ điển gồm 'so_quan_nan' và 'ban_do'.
    """
    dem = ds.groupby("neighbourhood", observed=False).size().reset_index(name="n")
    ban_do_raw = geo.merge(dem, on="neighbourhood", how="left")
    so_quan_nan = int(ban_do_raw["n"].isna().sum())
    ban_do = ban_do_raw.copy()
    ban_do["n"] = ban_do["n"].fillna(0).astype(int)
    return {"so_quan_nan": so_quan_nan, "ban_do": ban_do}


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
    }
)
demo_quan_ban_do = [
    "Santiago",
    "Providencia",
    "Las Condes",
    "Ñuñoa",
    "Lo Barnechea",
    "Pirque",
]
geo_demo = pd.DataFrame(
    {
        "neighbourhood": demo_quan_ban_do,
        "neighbourhood_group": [None] * 6,
    }
)

kq_c1 = count_by_area_buoc(demo_ds, geo_demo)
print("Số quận không có chỗ ở (Cách 1):", kq_c1["so_quan_nan"])
print("Bảng kết quả sau khi ghép Cách 1:")
print(kq_c1["ban_do"])
Giải thích chi tiết Cách 1 ​

groupby(...).size() đếm mọi dòng trong mỗi nhóm, kể cả dòng thiếu giá. reset_index(name="n") tạo bảng đếm hai cột để ghép. Vì bảng đếm có một dòng cho mỗi tên quận và geo có khóa duy nhất, phép ghép trái giữ sáu dòng bản đồ trong bài.

Đếm ngay sau ghép giúp lưu được số quận không khớp trước khi điền. fillna(0).astype(int) chuyển cột số đếm thành số nguyên. Không áp quy tắc điền không này cho cột giá: Không biết giá không đồng nghĩa giá bằng không.

Kết quả thực thi Cách 1 ​
neighbourhoodn
Santiago18
Providencia12
Las Condes8
Ñuñoa6
Lo Barnechea4
Pirque0

so_quan_nan bằng 1, tổng n bằng 48. Các cột ban đầu của geo được giữ.

Cách 2: Sử dụng Series.map vector hóa và kiểm tra tính toàn vẹn dữ liệu ​

python
import pandas as pd


def count_by_area_map(ds: pd.DataFrame, geo: pd.DataFrame) -> dict:
    """Ghép số lượng chỗ ở bằng ánh xạ Series.map vector hóa.

    Tham số:
        ds: DataFrame chứa cột 'neighbourhood'.
        geo: DataFrame hoặc GeoDataFrame chứa cột 'neighbourhood'.
    Trả về:
        Từ điển gồm 'so_quan_nan' và 'ban_do'.
    """
    counts = ds["neighbourhood"].value_counts()
    ban_do = geo.copy()
    so_quan_nan = int((~ban_do["neighbourhood"].isin(counts.index)).sum())
    ban_do["n"] = ban_do["neighbourhood"].map(counts).fillna(0).astype(int)
    return {"so_quan_nan": so_quan_nan, "ban_do": ban_do}


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame({"neighbourhood": _QUAN})
geo_demo = pd.DataFrame(
    {
        "neighbourhood": [
            "Santiago",
            "Providencia",
            "Las Condes",
            "Ñuñoa",
            "Lo Barnechea",
            "Pirque",
        ],
        "neighbourhood_group": [None] * 6,
    }
)

kq_c2 = count_by_area_map(demo_ds, geo_demo)
print(kq_c2["ban_do"].to_string(index=False))
print(
    "Giá trị số chỗ ở của quận Pirque:",
    kq_c2["ban_do"].loc[kq_c2["ban_do"]["neighbourhood"] == "Pirque", "n"].item(),
)
Giải thích chi tiết Cách 2 ​

Khi chỉ cần một chỉ số đếm, value_counts() tạo Series có là tên quận. Series.map(counts) tra số đếm tương ứng cho mỗi tên trong bảng ranh giới. Các tên không có trong Series nhận để có thể đếm và xử lý theo cùng quy tắc.

Cách viết này tránh đưa thêm các cột không cần thiết vào phép ghép, nhưng vẫn phải tạo và tra cứu cấu trúc đếm. Không thể kết luận nó nhanh hơn merge trên mọi kích thước bảng nếu chưa đo. Cần giữ bản sao bảng đích và thứ tự dòng khi gán cột mới.

Kết quả thực thi Cách 2 ​
neighbourhoodn
Santiago18
Providencia12
Las Condes8
Ñuñoa6
Lo Barnechea4
Pirque0

so_quan_nan cũng bằng 1. và các cột ranh giới được giữ để vẽ tiếp.

So sánh hai cách ​

Hai cách cùng ánh xạ số đếm lên tên quận. Merge thuận tiện khi cần thêm nhiều chỉ số trong một bảng tổng hợp. Map biểu đạt gọn việc tra một chỉ số theo khóa. Cả hai cần kiểm tra tên không khớp trước khi diễn giải các số không.


Bài 3: Xây dựng cặp bản đồ choropleth đối chiếu giá trung vị và quy mô nguồn cung chỗ ở ​

Mục tiêu: Thiết kế và kết xuất cặp bản đồ phân bố màu sắc (choropleth map) song hành từ dữ liệu hình học tổng hợp (synthetic polygon GeoDataFrame), đối chiếu trực tiếp giữa mức giá trung vị và quy mô nguồn cung chỗ ở thực tế, qua đó nhận diện và giải thích bẫy diện tích địa lý (Geographical Area Bias) mà không suy diễn thiên kiến về an toàn hay mức độ ưa chuộng.

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

  • Bảng chỗ ở ds: Chứa cột neighbourhood và cột price.
  • Bảng ranh giới địa lý tổng hợp geo_synth: Đối tượng GeoDataFrame chứa 6 đa giác hình chữ nhật mô phỏng 6 quận (Santiago, Providencia, Las Condes, Ñuñoa, Lo Barnechea, Pirque) được khởi tạo bằng thư viện Shapely, hệ tọa độ chuẩn EPSG:3857.

Thao tác yêu cầu:

  1. Xây dựng hàm plot_dual_choropleth(ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str) -> gpd.GeoDataFrame.
  2. Tính toán hai chỉ số kinh tế song song cho từng quận:
    • Mức giá trung vị (gia): Tính trung vị của cột price sau khi bỏ giá trị khuyết.
    • Số lượng cơ sở lưu trú (n): Đếm số dòng của từng quận.
  3. Ghép hai chỉ số trên vào bảng ranh giới geo_synth. Quận không có chỗ ở (Pirque) phải được điền 0 cho cột n và giữ NaN cho cột gia.
  4. Tạo khung vẽ gồm hai đồ thị con nằm cạnh nhau (1 hàng, 2 cột) với kích thước figsize=(13, 5.5).
  5. Bản đồ thứ nhất (bên trái): Thể hiện mức giá trung vị (gia) với dải màu ấm YlOrRd, thiết lập màu xám nhạt (#e0e0e0) cho các quận không có dữ liệu giá (missing_kwds).
  6. Bản đồ thứ hai (bên phải): Thể hiện quy mô nguồn cung (n) với dải màu lam Blues.
  7. Tắt khung trục tọa độ (ax.set_axis_off()) trên cả hai bản đồ, ghi nhãn tên quận và giá trị tương ứng lên tâm hình học (centroid) của từng đa giác.
  8. Bổ sung tiêu đề chung và dòng chú thích phương pháp luận ở góc dưới khung tranh.
  9. Kết xuất biểu đồ ra tệp hình ảnh định dạng vector SVG tại đường dẫn path.

Trường hợp biên cần chú ý: Quận Pirque không có bất kỳ chỗ ở nào nên cột giá mang giá trị NaN, bản đồ giá phải xử lý tô màu xám phân biệt thay vì phát sinh lỗi render.

Lời giải

Hai bản đồ trả lời hai câu hỏi ​

Bản đồ giá cho trung vị của những chỗ ở có giá, còn bản đồ số lượng cho số dòng chỗ ở được quan sát. Một quận có trung vị cao chưa chắc có nhiều niêm yết. Các giá trị này cũng chưa cho biết số lượt đặt phòng, mức độ an toàn hay sở thích của khách.

Các đa giác trong fixture đều là hình vuông cùng diện tích. Chúng giúp kiểm tra màu và phép ghép nhưng không minh họa khác biệt diện tích thực giữa các quận. Khi dùng ranh giới thật, diện tích mảng màu có thể ảnh hưởng mức chú ý của người xem. Bản đồ số lượng bổ sung quy mô, còn muốn so sánh mật độ phải tính số lượng trên một đơn vị diện tích phù hợp.

Cách 1: Dựng cặp bản đồ tuần tự với Shapely Box và GeoPandas Plot ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from shapely.geometry import box
import geopandas as gpd


def tao_geo_tong_hop() -> gpd.GeoDataFrame:
    """Tạo GeoDataFrame mô phỏng lưới không gian 6 quận thành phố."""
    o_luoi = [
        box(0, 1, 1, 2),  # Santiago (trung tâm nội đô)
        box(1, 1, 2, 2),  # Providencia (khu thương mại)
        box(2, 1, 3, 2),  # Las Condes (khu tài chính)
        box(0, 0, 1, 1),  # Ñuñoa (khu dân cư)
        box(1, 0, 2, 1),  # Lo Barnechea (ngoại ô diện tích lớn)
        box(2, 0, 3, 1),  # Pirque (vùng đệm ngoại vi)
    ]
    return gpd.GeoDataFrame(
        {
            "neighbourhood": [
                "Santiago",
                "Providencia",
                "Las Condes",
                "Ñuñoa",
                "Lo Barnechea",
                "Pirque",
            ],
            "neighbourhood_group": [None] * 6,
        },
        geometry=o_luoi,
        crs="EPSG:3857",
    )


def plot_dual_choropleth(
    ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str
) -> gpd.GeoDataFrame:
    """Vẽ cặp bản đồ choropleth đối chiếu giá trung vị và số lượng chỗ ở."""
    kpi_gia = (
        ds.dropna(subset=["price"])
        .groupby("neighbourhood")["price"]
        .median()
        .reset_index(name="gia")
    )
    kpi_so = ds.groupby("neighbourhood").size().reset_index(name="n")
    kpi = pd.merge(kpi_so, kpi_gia, on="neighbourhood", how="left")
    ban_do = geo.merge(kpi, on="neighbourhood", how="left")
    ban_do["n"] = ban_do["n"].fillna(0).astype(int)

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.5))

    # Bản đồ 1: Mức giá trung vị
    ban_do.plot(
        column="gia",
        cmap="YlOrRd",
        legend=True,
        edgecolor="#444",
        linewidth=0.8,
        ax=ax1,
        missing_kwds={"color": "#e0e0e0", "label": "Không có chỗ ở"},
        legend_kwds={"label": "Giá trung vị (CLP/đêm)", "shrink": 0.7},
    )
    ax1.set_title(
        "Bản đồ 1: Mức giá trung vị theo quận", fontsize=11, fontweight="bold"
    )
    ax1.set_axis_off()

    # Bản đồ 2: Quy mô nguồn cung
    ban_do.plot(
        column="n",
        cmap="Blues",
        legend=True,
        edgecolor="#444",
        linewidth=0.8,
        ax=ax2,
        legend_kwds={"label": "Số lượng chỗ ở niêm yết (n)", "shrink": 0.7},
    )
    ax2.set_title(
        "Bản đồ 2: Quy mô nguồn cung thực tế theo quận", fontsize=11, fontweight="bold"
    )
    ax2.set_axis_off()

    # Ghi nhãn trực tiếp trên tâm hình học của từng quận
    for _, row in ban_do.iterrows():
        pt = row.geometry.centroid
        gia_str = f"{row['gia']/1000:.0f}k" if pd.notna(row["gia"]) else "Trắng"
        ax1.text(
            pt.x,
            pt.y,
            f"{row['neighbourhood']}\n{gia_str}",
            ha="center",
            va="center",
            fontsize=8,
            color="#222",
        )
        ax2.text(
            pt.x,
            pt.y,
            f"{row['neighbourhood']}\nn={row['n']}",
            ha="center",
            va="center",
            fontsize=8,
            color="#222",
        )

    fig.suptitle(
        "Cặp bản đồ đối chiếu: Phân khúc giá và Quy mô nguồn cung (Tránh bẫy diện tích địa lý)",
        fontsize=13,
        fontweight="bold",
        y=0.98,
    )
    fig.text(
        0.02,
        0.02,
        "Lưu ý phương pháp luận: Quận diện tích lớn có giá cao nhưng nguồn cung ít, không suy diễn mức độ an toàn hay độ ưa chuộng từ giá.",
        fontsize=8,
        color="#555",
    )

    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    plt.close(fig)
    return ban_do


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
    }
)
geo_synth_demo = tao_geo_tong_hop()
duong_dan_svg = "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-03-cap-ban-do-gia-va-so-luong.svg"
ban_do_kq_c1 = plot_dual_choropleth(demo_ds, geo_synth_demo, duong_dan_svg)
print("Kết xuất cặp bản đồ Cách 1 thành công. Số quận trên bản đồ:", len(ban_do_kq_c1))
Giải thích chi tiết Cách 1 ​

box tạo các hình chữ nhật giả lập theo bốn tọa độ góc. Gán CRS không biến chúng thành vị trí thực của các quận. Với ví dụ này, centroid chỉ dùng để đặt nhãn ở giữa ô vuông. Trên bản đồ thật, centroid có thể nằm ngoài một đa giác lõm và không phải lúc nào cũng là vị trí nhãn thích hợp.

Sau khi tổng hợp, ghép trái giữ đủ sáu quận. missing_kwds đặt màu xám cho giá để khác với một mức giá bằng không. Hai Axes có thanh màu riêng vì giá và số đếm khác đơn vị. Khi so sánh nhiều snapshot, cần xét thêm việc giữ cùng giới hạn thang màu cho cùng một chỉ số.

Kết quả thực thi Cách 1 ​
neighbourhoodnGiá trung vị (CLP/đêm)
Santiago1859271.0
Providencia1267651.0
Las Condes887029.5
Ñuñoa672679.0
Lo Barnechea463314.5
Pirque0
Hình 13.2: Bản đồ choropleth phân bố nguồn cung chỗ ở theo quận
Hình 13.2: Bản đồ choropleth phân bố nguồn cung chỗ ở theo quận
Hình 13.3: Cặp bản đồ đối chiếu giá trung vị và quy mô nguồn cung chỗ ở
Hình 13.3: Cặp bản đồ đối chiếu giá trung vị và quy mô nguồn cung chỗ ở

Cách 2: Tự động hóa thanh màu liên tục với colorbar độc lập và định dạng SVG tối ưu ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import matplotlib.cm as cm
import matplotlib.colors as mcolors
import numpy as np
import pandas as pd
from shapely.geometry import box
import geopandas as gpd


def plot_dual_choropleth_chuyen_nghiep(
    ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str
) -> gpd.GeoDataFrame:
    """Vẽ cặp bản đồ không gian với thanh tỷ lệ màu sắc chuẩn hóa và kiểm soát ngoại biên."""
    kpi = (
        ds.groupby("neighbourhood")
        .agg(gia=("price", "median"), n=("id", "size"))
        .reset_index()
    )
    ban_do = geo.merge(kpi, on="neighbourhood", how="left")
    ban_do["n"] = ban_do["n"].fillna(0).astype(int)

    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5.5))

    # Căn chỉnh thanh màu cho bản đồ giá
    gia_hop_le = ban_do["gia"].dropna()
    norm_gia = mcolors.Normalize(vmin=gia_hop_le.min(), vmax=gia_hop_le.max())
    cmap_gia = cm.YlOrRd
    ban_do.plot(
        column="gia",
        cmap=cmap_gia,
        norm=norm_gia,
        edgecolor="#333",
        linewidth=0.7,
        missing_kwds={"color": "#eeeeee"},
        ax=ax1,
    )
    sm1 = cm.ScalarMappable(norm=norm_gia, cmap=cmap_gia)
    fig.colorbar(
        sm1,
        ax=ax1,
        orientation="horizontal",
        fraction=0.046,
        pad=0.04,
        label="Giá trung vị (CLP)",
    )
    ax1.set_title("Phân bố mức giá trung vị", fontsize=11, fontweight="bold")
    ax1.set_axis_off()

    # Căn chỉnh thanh màu cho bản đồ số lượng chỗ ở
    norm_n = mcolors.Normalize(vmin=0, vmax=ban_do["n"].max())
    cmap_n = cm.Blues
    ban_do.plot(
        column="n", cmap=cmap_n, norm=norm_n, edgecolor="#333", linewidth=0.7, ax=ax2
    )
    sm2 = cm.ScalarMappable(norm=norm_n, cmap=cmap_n)
    fig.colorbar(
        sm2,
        ax=ax2,
        orientation="horizontal",
        fraction=0.046,
        pad=0.04,
        label="Số lượng chỗ ở (n)",
    )
    ax2.set_title("Quy mô nguồn cung chỗ ở", fontsize=11, fontweight="bold")
    ax2.set_axis_off()

    fig.suptitle(
        "Khảo sát không gian đô thị: So sánh chất lượng phân khúc và dung lượng nguồn cung",
        fontsize=12,
        fontweight="bold",
    )
    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    plt.close(fig)
    return ban_do


def tao_geo_tong_hop() -> gpd.GeoDataFrame:
    """Tạo GeoDataFrame mô phỏng lưới không gian 6 quận thành phố."""
    o_luoi = [
        box(0, 1, 1, 2),  # Santiago (trung tâm nội đô)
        box(1, 1, 2, 2),  # Providencia (khu thương mại)
        box(2, 1, 3, 2),  # Las Condes (khu tài chính)
        box(0, 0, 1, 1),  # Ñuñoa (khu dân cư)
        box(1, 0, 2, 1),  # Lo Barnechea (ngoại ô diện tích lớn)
        box(2, 0, 3, 1),  # Pirque (vùng đệm ngoại vi)
    ]
    return gpd.GeoDataFrame(
        {
            "neighbourhood": [
                "Santiago",
                "Providencia",
                "Las Condes",
                "Ñuñoa",
                "Lo Barnechea",
                "Pirque",
            ],
            "neighbourhood_group": [None] * 6,
        },
        geometry=o_luoi,
        crs="EPSG:3857",
    )


_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)

demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
    }
)

# Chạy ví dụ
geo_synth_demo = tao_geo_tong_hop()
ban_do_kq_c2 = plot_dual_choropleth_chuyen_nghiep(
    demo_ds,
    geo_synth_demo,
    "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-03-cap-ban-do-gia-va-so-luong.svg",
)
print(ban_do_kq_c2[["neighbourhood", "n", "gia"]].to_string(index=False))
Giải thích chi tiết Cách 2 ​

Normalize(vmin, vmax) ánh xạ giá trị số lên thang màu. ScalarMappable cung cấp đối tượng dùng để tạo colorbar riêng của Matplotlib. Các tham số orientation, fraction và pad điều chỉnh hướng, độ lớn và khoảng cách thanh màu với bản đồ.

Những thiết lập này điều chỉnh bố cục, không đổi số liệu tổng hợp. Giá và số đếm vẫn cần hai thanh màu có nhãn đơn vị khác nhau. SVG lưu các hình và đường dưới dạng vector, nhưng chữ, chú giải và tỉ lệ bố cục vẫn cần kiểm tra ở kích thước hiển thị thực tế.

Kết quả thực thi Cách 2 ​
QuậnnTỷ trọng trong 48 dòng (%)Giá trung vị (CLP)
Santiago1837.559271.0
Providencia1225.067651.0
Las Condes816.787029.5
Ñuñoa612.572679.0
Lo Barnechea48.363314.5
Pirque00.0

So sánh hai cách ​

Cách 1 để GeoPandas dựng phần lớn chú giải màu. Cách 2 điều khiển colorbar bằng các đối tượng Matplotlib riêng. Cả hai phải giữ Pirque có n bằng 0 và giá . Trên mẫu, Las Condes có trung vị cao nhất còn Santiago có nhiều dòng nhất.


Bài 4: Thống kê chuỗi thời gian số lượng đánh giá theo chu kỳ hàng năm trong khoảng xác định ​

Mục tiêu: Xử lý chuỗi thời gian chứa cột ngày tháng (datetime64), thực hiện tổng hợp tái lấy mẫu (resample) theo tần suất kết thúc năm dương lịch (YE), cắt lát dữ liệu trong một khoảng thời gian xác định và bảo đảm các năm không có quan sát nhận giá trị 0.

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

  • Bảng đánh giá rv: Chứa cột date kiểu dữ liệu thời gian chuẩn datetime64[ns].
  • Hai tham số chuỗi biểu thị năm bắt đầu bat_dau (ví dụ "2016") và năm kết thúc ket_thuc (ví dụ "2025").

Thao tác yêu cầu:

  1. Xây dựng hàm yearly_counts(rv: pd.DataFrame, bat_dau: str, ket_thuc: str) -> pd.Series.
  2. Chuyển cột date thành thời gian DatetimeIndex, thực hiện phép tái lấy mẫu theo chu kỳ cuối năm dương lịch bằng cú pháp resample("YE").size().
  3. Cắt lát chuỗi dữ liệu trong dải thời gian từ bat_dau đến ket_thuc bằng cú pháp .loc[bat_dau:ket_thuc].
  4. Bảo đảm của Series kết quả là DatetimeIndex với các nhãn là ngày cuối cùng của từng năm (YYYY-12-31).
  5. Không chỉnh sửa bảng dữ liệu rv đầu vào.
  6. Giải thích vì sao khi thống kê sản lượng hàng năm, người phân tích cần tách rõ năm hiện tại chưa kết thúc khi so sánh với tổng các năm đầy đủ (như năm 2026 với mốc chụp snapshot vào tháng 6).

Trường hợp biên cần chú ý: Nếu trong khoảng thời gian có một năm hoàn toàn không có đánh giá nào, hàm resample phải giữ nguyên năm đó với số đếm bằng 0 thay vì làm mất của năm.

Lời giải

Đếm đánh giá theo khoảng thời gian ​

Mỗi bản ghi đánh giá đóng góp một đơn vị vào năm chứa ngày của nó. Số đánh giá là một đại lượng quan sát được, không đồng nhất với số lượt đặt phòng. Muốn dùng nó để ước lượng giao dịch phải bổ sung giả định về việc khách để lại đánh giá.

Để so sánh các tổng năm trọn vẹn, bài này lấy 2016–2025 và tách năm 2026 chưa kết thúc khỏi dải đó. Có thể trình bày năm chưa trọn nếu gắn nhãn rõ, hoặc so cùng kỳ giữa các năm. Không được coi tổng chưa trọn và tổng đủ năm là cùng độ dài quan sát.

Cách 1: Sử dụng set_index kết hợp resample và cắt lát nhãn chuỗi ​

python
import pandas as pd


def yearly_counts_resample(rv: pd.DataFrame, bat_dau: str, ket_thuc: str) -> pd.Series:
    """Đếm số lượng đánh giá theo từng năm trong khoảng xác định bằng resample.

    Tham số:
        rv: DataFrame chứa cột 'date' kiểu datetime64.
        bat_dau: Chuỗi năm bắt đầu (ví dụ '2016').
        ket_thuc: Chuỗi năm kết thúc (ví dụ '2025').
    Trả về:
        Series với chỉ mục DatetimeIndex biểu thị ngày cuối năm.
    """
    chuoi_nam = rv.set_index("date").resample("YE").size()
    return chuoi_nam.loc[bat_dau:ket_thuc]


# Dữ liệu thử nghiệm minh họa
_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}
demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)

nam_kq_c1 = yearly_counts_resample(demo_rv, "2016", "2025")
print("Kết quả Cách 1 (Dải năm 2016-2025):")
print(nam_kq_c1)
Giải thích chi tiết Cách 1 ​

set_index("date") đưa ngày vào DatetimeIndex để resample gom theo thời gian. resample("YE").size() đếm số dòng trong từng năm và gắn nhãn ngày cuối năm. Một năm không có dòng nhưng nằm giữa các năm có dữ liệu vẫn có nhóm với số đếm bằng 0.

.loc[bat_dau:ket_thuc] dùng chuỗi năm và bao gồm cả hai đầu. Hàm không sửa cột date trên bảng gốc. Resample chỉ phủ dải thời gian của dữ liệu, nên nếu cần các năm yêu cầu nằm ngoài dải này, phải tạo dải chỉ mục mục tiêu rồi reindex rõ ràng.

Kết quả thực thi Cách 1 ​
Ngày Số đánh giá
2016-12-31120
2017-12-31210
2018-12-31330
2019-12-31420
2020-12-3190
2021-12-31150
2022-12-31380
2023-12-31520
2024-12-31610
2025-12-31700

Cách 2: Trích xuất thuộc tính năm kết hợp reindex dải ngày liên tục ​

python
import pandas as pd


def yearly_counts_reindex(rv: pd.DataFrame, bat_dau: str, ket_thuc: str) -> pd.Series:
    """Đếm số lượng đánh giá bằng trích xuất năm và tái chỉ mục dải ngày.

    Tham số:
        rv: DataFrame chứa cột 'date'.
        bat_dau: Chuỗi năm bắt đầu.
        ket_thuc: Chuỗi năm kết thúc.
    Trả về:
        Series có chỉ mục DatetimeIndex tương đương.
    """
    counts = rv["date"].dt.year.value_counts()
    start_y, end_y = int(bat_dau), int(ket_thuc)
    full_idx = pd.date_range(start=f"{start_y}-12-31", end=f"{end_y}-12-31", freq="YE")
    s = counts.reindex(range(start_y, end_y + 1), fill_value=0).astype(int)
    s.index = full_idx
    s.index.name = "date"
    s.name = None
    return s


_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}

demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)

# Kiểm thử tính đồng nhất
nam_kq_c2 = yearly_counts_reindex(demo_rv, "2016", "2025")
print(nam_kq_c2.to_string())
Giải thích chi tiết Cách 2 ​

.dt.year lấy năm từ từng ngày. value_counts() đếm năm, còn pd.date_range(..., freq="YE") tạo đầy đủ các mốc cuối năm yêu cầu. Tra counts.get(dt.year, 0) đặt số không cho năm không có trong bảng đếm.

Tạo dải năm riêng giúp biểu thị cả năm chưa có quan sát ở đầu hoặc cuối khoảng yêu cầu, khác với chỉ cắt dải resample đã có. Cả hai cách giả định cột ngày đã là dữ liệu thời gian hợp lệ. Ngày thiếu hoặc không phân tích được cần quy tắc xử lý trước bước tổng hợp.

Kết quả thực thi Cách 2 ​
Ngày Số đánh giá
2016-12-31120
2017-12-31210
2018-12-31330
2019-12-31420
2020-12-3190
2021-12-31150
2022-12-31380
2023-12-31520
2024-12-31610
2025-12-31700

So sánh hai cách ​

Cách 1 bám trực tiếp thao tác gom theo thời gian của pandas. Cách 2 dựng dải năm mục tiêu rồi tra số đếm. Trên fixture, hai cách trả mười mốc cuối năm từ 2016 đến 2025. Nếu chọn riêng năm 2026, số đếm là 260 nhưng phải ghi đây là dữ liệu chưa đủ năm.


Bài 5: Phân tích định lượng độ lệch tăng trưởng khi chọn mốc so sánh và phản biện kỳ gốc ​

Mục tiêu: Xây dựng phép tính tỷ số số đánh giá giữa một năm đích với các năm cơ sở khác nhau, phân tích hiện tượng thao túng mốc so sánh (selective baseline / cherry-picking) và giải thích thông tin bị thiếu khi chỉ xem một kỳ gốc.

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

  • Series chuỗi thời gian nam: là DatetimeIndex biểu thị ngày cuối năm, giá trị là số lượng đánh giá tích lũy của năm đó.
  • Tham số nam_cuoi: Năm mục tiêu cần so sánh (kiểu số nguyên, ví dụ 2025).
  • Tham số cac_moc: Danh sách các năm cơ sở cần đối chiếu (kiểu danh sách số nguyên, ví dụ [2020, 2019, 2016]).

Thao tác yêu cầu:

  1. Xây dựng hàm compare_baselines(nam: pd.Series, nam_cuoi: int, cac_moc: list) -> dict.
  2. Trích xuất số lượng đánh giá của năm nam_cuoi.
  3. Duyệt qua từng năm mốc trong cac_moc, tính tỷ số R=a/b. Ở đây, a là số đánh giá năm cuối và b là số đánh giá năm mốc, với b>0.
  4. Trả về từ điển có khóa là năm mốc (số nguyên) và giá trị là tỷ số tăng trưởng (số thực float chính xác, không làm tròn), bảo toàn đúng thứ tự xuất hiện của cac_moc.
  5. Không chỉnh sửa Series nam đầu vào.
  6. Lập bảng so sánh định lượng và giải thích vì sao việc chỉ chọn duy nhất năm 2020 làm mốc so sánh lại tạo ra một kết luận phóng đại sai lệch.

Trường hợp biên cần chú ý: Mọi năm trong cac_moc và nam_cuoi đều được bảo đảm có mặt trong Series nam. Mẫu số luôn là số nguyên dương lớn hơn 0.

Lời giải

Tỷ số đúng vẫn cần nói rõ kỳ gốc ​

Gọi a là số đánh giá năm cuối và b là số đánh giá năm mốc. Tỷ số a chia b mô tả năm cuối bằng bao nhiêu lần năm mốc. Mức tăng phần trăm là tỷ số đó trừ 1 rồi nhân 100. Hai cách diễn đạt khác nhau, nên cần ghi rõ đang báo tỷ số hay phần trăm tăng.

Trong fixture, 700 chia 90 xấp xỉ 7,78, còn 700 chia 420 xấp xỉ 1,67. Cả hai phép chia đều đúng, nhưng trả lời hai câu hỏi với hai mốc khác nhau. Chỉ hiển thị từ năm có số đếm thấp có thể làm người đọc thiếu bối cảnh. Các số giả lập này không chứng minh tốc độ phục hồi kinh tế hoặc nguyên nhân của mức thấp năm 2020.

Cách 1: Vòng lặp tra cứu boolean theo chỉ mục thời gian ​

python
import pandas as pd


def compare_baselines_loop(nam: pd.Series, nam_cuoi: int, cac_moc: list) -> dict:
    """Tính tỷ số tăng trưởng so với các năm mốc theo cách duyệt tuần tự.

    Tham số:
        nam: Series có chỉ mục DatetimeIndex.
        nam_cuoi: Năm đích cần so sánh.
        cac_moc: Danh sách các năm làm kỳ gốc.
    Trả về:
        Từ điển {năm_mốc: tỷ_số_float}.
    """
    val_cuoi = float(nam[nam.index.year == nam_cuoi].iloc[0])
    ket_qua = {}
    for moc in cac_moc:
        val_moc = float(nam[nam.index.year == moc].iloc[0])
        ket_qua[moc] = val_cuoi / val_moc
    return ket_qua


# Dữ liệu thử nghiệm minh họa
_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}
demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)
nam_demo = demo_rv.set_index("date").resample("YE").size()

ti_le_c1 = compare_baselines_loop(nam_demo, 2025, [2020, 2019, 2016])
print("Kết quả tính toán tỷ số tăng trưởng Cách 1:")
for moc, ti_so in ti_le_c1.items():
    print(f" - Năm 2025 so với năm {moc}: {ti_so:.2f} lần (tương đương {ti_so:.4f})")
Giải thích chi tiết Cách 1 ​

nam.index.year == nam_cuoi tạo mặt nạ chọn năm cuối. .iloc[0] lấy một giá trị trong kết quả lọc và float chuyển nó thành số thực Python. Với mỗi năm mốc, hàm lọc tương tự rồi chia giá trị cuối cho giá trị mốc.

Vòng lặp duyệt cac_moc theo thứ tự đầu vào, nên từ điển giữ thứ tự đó. Các năm trong bài đã có đủ và số mốc dương, tránh lỗi lấy phần tử rỗng hoặc chia cho không. Không làm tròn trong hàm để phép tính tiếp theo dùng đủ độ chính xác số thực đang có, rồi chỉ làm tròn khi trình bày.

Kết quả thực thi Cách 1 ​
Năm mốcSố đánh giá năm mốcSố năm 2025Tỷ số, chưa làm tròn trong hàm
2020907007.777777777777778
20194207001.6666666666666667
20161207005.833333333333333
Hình 13.4: Biểu đồ gốc có lỗi chọn mốc so sánh phóng đại mức tăng trưởng
Hình 13.4: Biểu đồ gốc có lỗi chọn mốc so sánh phóng đại mức tăng trưởng

Cách 2: Tái lập chỉ mục số nguyên và phép chia từ điển nén (Dict Comprehension) ​

python
import pandas as pd


def compare_baselines_dict(nam: pd.Series, nam_cuoi: int, cac_moc: list) -> dict:
    """Tính tỷ số tăng trưởng thông qua Series chỉ mục số nguyên năm.

    Tham số:
        nam: Series có chỉ mục DatetimeIndex.
        nam_cuoi: Năm đích cần so sánh.
        cac_moc: Danh sách các năm làm kỳ gốc.
    Trả về:
        Từ điển kết quả.
    """
    nam_int = pd.Series(nam.values, index=nam.index.year)
    val_cuoi = float(nam_int.loc[nam_cuoi])
    return {moc: val_cuoi / float(nam_int.loc[moc]) for moc in cac_moc}


_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}

demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)

nam_demo = demo_rv.set_index("date").resample("YE").size()

ti_le_c2 = compare_baselines_dict(nam_demo, 2025, [2020, 2019, 2016])
print(ti_le_c2)
Giải thích chi tiết Cách 2 ​

Tạo Series có là năm giúp tránh lặp lại mặt nạ so sánh trên DatetimeIndex cho mỗi mốc. Dict comprehension duyệt cac_moc và tạo các cặp năm, tỷ số trong cùng thứ tự.

Cách này giả định mỗi năm chỉ có một giá trị tổng. Nếu nhiều dòng cùng năm, tra loc có thể trả Series thay vì một số. số nguyên không tự bảo đảm thời gian tra cứu hằng số trong mọi trường hợp của pandas. Khi dùng lại, cần kiểm tra tính duy nhất của năm và cùng miền dữ liệu như Cách 1.

Kết quả thực thi Cách 2 ​
Năm mốcSố đánh giá năm mốcSố năm 2025Tỷ số, chưa làm tròn trong hàm
2020907007.777777777777778
20194207001.6666666666666667
20161207005.833333333333333

So sánh hai cách ​

Cách 1 mở rõ từng bước lọc năm và chia số. Cách 2 chuẩn bị theo năm một lần để tra các mốc. Không gọi tỷ lệ giữa hai tỷ số với mốc khác nhau là “hệ số dối trá” của đồ họa: Muốn đánh giá mức phóng đại hình học phải định nghĩa hiệu ứng thị giác và hiệu ứng số liệu đang so sánh.


Bài 6: Thiết lập biểu đồ xu hướng trung thực dài hạn và hồ sơ phản biện biểu đồ thiên lệch ​

Mục tiêu: Xây dựng hàm kết xuất biểu đồ xu hướng dài hạn trung thực, khắc phục triệt để các thủ thuật cắt gọt chuỗi thời gian, đánh dấu trực quan giai đoạn khủng hoảng ngoại sinh (đại dịch COVID-19), đồng thời lập hồ sơ phản biện lỗi (Chart Critique Dossier) bóc trần sự sai lệch của biểu đồ thiên kiến.

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

  • Series chuỗi thời gian nam: là DatetimeIndex biểu thị các năm trọn vẹn (ví dụ 10 năm từ 2016 đến 2025).
  • Tham số path: Đường dẫn tệp hình ảnh đầu ra cần lưu trữ.
  • Tham số covid: Tuple chứa khoảng năm diễn ra đại dịch (mặc định (2020, 2021)).

Thao tác yêu cầu:

  1. Xây dựng hàm plot_fixed_trend(nam: pd.Series, path: str, covid: tuple = (2020, 2021)) -> plt.Axes.
  2. Tạo khung vẽ mới với tỷ lệ chuẩn mực figsize=(8.5, 3.8).
  3. Vẽ một đường duy nhất biểu diễn toàn bộ các điểm dữ liệu của nam với trục hoành là số nguyên năm (nam.index.year).
  4. Đánh dấu giai đoạn đại dịch bằng vùng bóng mờ ax.axvspan(covid[0] - 0.5, covid[1] + 0.5, color="#fee0d2", alpha=0.6) hoặc hộp chú thích chữ nổi bật.
  5. Đặt tiêu đề khoa học khách quan (độ dài ≥15 ký tự, không dùng từ ngữ giật gân, phóng đại), đặt nhãn trục tung rõ ràng.
  6. Lưu biểu đồ ra tệp hình ảnh theo đường dẫn path với chất lượng cao (dpi=150, bbox_inches="tight"), và trả về đối tượng Axes.
  7. Lập hồ sơ lỗi hoàn chỉnh cho biểu đồ gốc gồm 3 luận điểm: Lỗi chọn kỳ gốc, Lỗi từ ngữ giật gân so với bản chất số liệu, và Sự khác biệt căn bản của bản sửa trung thực.

Trường hợp biên cần chú ý: Hàm phải vẽ đầy đủ các năm trong Series đầu vào (không được tự ý cắt xén năm), vùng tô bóng axvspan phải bao trọn các năm đại dịch với khoảng mở rộng ±0,5 đơn vị để phủ kín bề rộng cột mốc năm.

Lời giải

Bổ sung bối cảnh thay vì gán nguyên nhân ​

Hồ sơ phản biện cần chỉ ra ba điểm. Hình bắt đầu năm 2020 bỏ các năm trước đó nên người đọc không thấy số đếm năm 2019 là 420. Tiêu đề “bùng nổ” khiến tỷ số số đánh giá dễ bị đọc thành kết luận về toàn thị trường. Bản sửa hiển thị dải 2016–2025, giữ nhãn đơn vị và chỉ rõ kỳ gốc khi nêu tỷ số.

Có thể tô giai đoạn 2020–2021 như một mốc bối cảnh. Tuy nhiên dải màu không chứng minh đại dịch gây ra biến đổi trong fixture được dựng bằng code. Với dữ liệu thật, quan sát một mức giảm cùng thời điểm sự kiện cũng chưa đủ xác định quan hệ nhân quả.

Cách 1: Sử dụng Matplotlib axvspan và chú giải trực quan ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd


def plot_fixed_trend(
    nam: pd.Series, path: str, covid: tuple = (2020, 2021)
) -> plt.Axes:
    """Vẽ biểu đồ xu hướng dài hạn trung thực có đánh dấu vùng biến cố ngoại sinh.

    Tham số:
        nam: Series chuỗi thời gian số đếm theo năm.
        path: Đường dẫn lưu tệp hình ảnh.
        covid: Tuple (năm_bắt_đầu, năm_kết_thúc) của giai đoạn biến cố.
    Trả về:
        Đối tượng Axes đã vẽ.
    """
    fig, ax = plt.subplots(figsize=(8.5, 3.8))
    years = nam.index.year
    ax.plot(
        years,
        nam.values,
        marker="o",
        color="#1f77b4",
        lw=2,
        markersize=6,
        label="Số đánh giá thực tế",
    )
    ax.axvspan(
        covid[0] - 0.5,
        covid[1] + 0.5,
        color="#fee0d2",
        alpha=0.6,
        label="Giai đoạn đại dịch COVID-19",
    )
    ax.set_title(
        "Xu hướng đánh giá dài hạn giai đoạn 2016–2025: Hồi phục sau đại dịch",
        fontsize=11,
        fontweight="bold",
    )
    ax.set_xlabel("Năm")
    ax.set_ylabel("Số lượt đánh giá hàng năm")
    ax.grid(True, linestyle="--", alpha=0.5)
    ax.legend(loc="upper left")

    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    return ax


# Dữ liệu thử nghiệm minh họa
_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}
demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)
nam_sua = demo_rv.set_index("date").resample("YE").size().loc["2016":"2025"]
duong_dan_sua = "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-05-ban-sua-trung-thuc-xu-huong.svg"

ax_kq_c1 = plot_fixed_trend(nam_sua, duong_dan_sua)
print("Kết xuất bản sửa Cách 1 thành công:")
print(" - Tiêu đề:", ax_kq_c1.get_title())
print(" - Số điểm trên đường:", len(ax_kq_c1.lines[0].get_xdata()))
print(" - Số vùng tô bóng:", len(ax_kq_c1.patches))
plt.close(ax_kq_c1.figure)
Giải thích chi tiết Cách 1 ​

ax.plot vẽ các số đếm theo năm, còn marker giúp nhận ra từng quan sát năm. axvspan tô một khoảng trên trục x và phủ chiều cao vùng vẽ. Cộng hoặc trừ 0.5 ở hai đầu làm vùng tô bao quanh các mốc năm tương ứng.

Tiêu đề và nhãn trục phải nói đang đếm đánh giá, không phải đặt phòng. savefig lưu hình tại đường dẫn yêu cầu. Dung lượng tệp không phải tiêu chí xác nhận hình đã đúng: Cần kiểm tra có đủ điểm, chữ đọc được và phần tô không che các giá trị cần xem.

Kết quả thực thi Cách 1 ​
Kiểm traKết quả trên fixture
Khoảng năm2016–2025, 10 giá trị
Năm 2019420
Năm 202090
Năm 2025700
Chỉ sốSố đánh giá hàng năm
Vùng tô2020–2021 theo tham số covid
Hình 13.5: Bản sửa trung thực xu hướng đánh giá dài hạn và đánh dấu biến cố đại dịch
Hình 13.5: Bản sửa trung thực xu hướng đánh giá dài hạn và đánh dấu biến cố đại dịch

Cách 2: Bổ sung chú thích văn bản trực tiếp tại các mốc trọng yếu ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd


def plot_fixed_trend_nang_cao(
    nam: pd.Series, path: str, covid: tuple = (2020, 2021)
) -> plt.Axes:
    """Vẽ biểu đồ xu hướng dài hạn với các chú thích số liệu trực quan tại các mốc ngoặt."""
    fig, ax = plt.subplots(figsize=(8.5, 4))
    years = nam.index.year
    ax.plot(
        years,
        nam.values,
        color="#08519c",
        lw=2.2,
        marker="s",
        markersize=5,
        label="Số lượng đánh giá",
    )
    ax.axvspan(
        covid[0] - 0.5,
        covid[1] + 0.5,
        color="#f0f0f0",
        edgecolor="#bdbdbd",
        linestyle="--",
        label="Mốc so sánh 2020–2021",
    )

    # Ghi chú văn bản tại các mốc trọng yếu
    ax.text(
        2020.5,
        nam.loc["2020-12-31"] + 30,
        "Mốc thấp của mẫu\n(90 lượt)",
        ha="center",
        fontsize=8,
        color="#a50f15",
        fontweight="bold",
    )
    ax.text(
        2019,
        nam.loc["2019-12-31"] + 25,
        "Đỉnh trước dịch\n(420 lượt)",
        ha="center",
        fontsize=8,
        color="#045a8d",
    )
    ax.text(
        2025,
        nam.loc["2025-12-31"] - 45,
        "Hiện tại\n(700 lượt)",
        ha="center",
        fontsize=8,
        color="#045a8d",
    )

    ax.set_title(
        "Số đánh giá theo năm trong dữ liệu minh họa", fontsize=11, fontweight="bold"
    )
    ax.set_xlabel("Năm quan sát")
    ax.set_ylabel("Số lượt đánh giá trong từng năm")
    ax.set_xticks(years)
    ax.grid(True, linestyle=":", alpha=0.6)
    ax.legend(loc="upper left", frameon=True)

    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    return ax


_SO_THEO_NAM = {
    2014: 40,
    2015: 70,
    2016: 120,
    2017: 210,
    2018: 330,
    2019: 420,
    2020: 90,
    2021: 150,
    2022: 380,
    2023: 520,
    2024: 610,
    2025: 700,
    2026: 260,
}

demo_rv = pd.DataFrame(
    {
        "date": [
            pd.Timestamp(f"{nam}-01-01")
            + pd.Timedelta(days=(k * 97) % (181 if nam == 2026 else 365))
            for nam, so in _SO_THEO_NAM.items()
            for k in range(so)
        ]
    }
)

nam_sua = demo_rv.set_index("date").resample("YE").size().loc["2016":"2025"]

duong_dan_sua = "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-05-ban-sua-trung-thuc-xu-huong.svg"

ax_kq_c2 = plot_fixed_trend_nang_cao(nam_sua, duong_dan_sua)
assert len(ax_kq_c2.texts) >= 3
print("Cách 2 kiểm thử thành công: Đã tạo đủ các chú thích chữ tại các mốc ngoặt.")
plt.close(ax_kq_c2.figure)
Giải thích chi tiết Cách 2 ​

ax.text đặt nội dung tại một tọa độ dữ liệu để đọc trực tiếp mốc đang được thảo luận. set_xticks(years) dùng các năm nguyên làm vạch chia, tránh một vạch nửa năm không có ý nghĩa với các tổng năm.

Chữ gắn vào tọa độ cần được kiểm tra lại khi dữ liệu thay đổi vì có thể trùng đường hoặc nhãn khác. Viền và màu của vùng tô chỉ giúp nhận diện giai đoạn. Chúng không cung cấp bằng chứng về một nguyên nhân kinh tế hoặc trạng thái “bình thường” của thị trường.

Kết quả thực thi Cách 2 ​
Nội dung phản biệnCách đọc bản sửa
Kỳ gốcĐọc cả các năm trước 2020 và đối chiếu nhiều mốc
Lời diễn giảiNêu tỷ số của số đánh giá, tránh gọi đó là mức tăng của toàn thị trường
Giai đoạn tô màuMốc bối cảnh cần nêu rõ, không phải kiểm định nhân quả
Điểm đánh dấu2019 có 420, 2020 có 90, 2025 có 700

So sánh hai cách ​

Cách 1 đánh dấu giai đoạn bằng vùng tô. Cách 2 bổ sung nhãn tại những mốc cần đọc chính xác. Cả hai vẫn cần kiểm tra bố cục và câu kết luận sau khi đổi dữ liệu, và không thể tự bảo đảm một diễn giải đúng chỉ bằng cách kéo dài trục thời gian.


Bài 7: Bài mở rộng nâng cao: Phân tích không gian đa biến kết hợp quy mô nguồn cung và tỷ lệ nhóm chủ nhà theo quận ​

Mục tiêu: Xây dựng thuật toán phân tích không gian đa chiều, tích hợp song song quy mô nguồn cung chỗ ở, mức giá trung vị và tỷ lệ niêm yết thuộc nhóm chủ nhà có từ năm chỗ ở theo từng đơn vị hành chính, trực quan hóa trên bản đồ phân vùng và phân tích cơ cấu thị trường mà không suy diễn thiên kiến nhân quả.

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

  • Bảng chỗ ở ds: Chứa thông tin về quận neighbourhood, giá price, mã định danh id và số lượng chỗ ở của chủ nhà calculated_host_listings_count.
  • Bảng ranh giới địa lý tổng hợp geo: Đối tượng GeoDataFrame chứa ranh giới 6 quận mô phỏng.

Thao tác yêu cầu:

  1. Xây dựng hàm analyze_spatial_concentration(ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str) -> gpd.GeoDataFrame.
  2. Phân loại chủ nhà thành hai nhóm: chuyen_nghiep (từ 5 chỗ ở trở lên) và ca_nhan (dưới 5 chỗ ở).
  3. Tổng hợp dữ liệu theo từng quận:
    • Tổng số chỗ ở (tong_cho_o): Đếm số lượng cơ sở lưu trú.
    • Mức giá trung vị (gia_trung_vi): Tính trung vị giá niêm yết (bỏ giá trị khuyết).
    • Số lượng chỗ ở chuyên nghiệp (so_chuyen_nghiep): Đếm số chỗ ở do chủ nhà chuyên nghiệp quản lý.
    • Tỷ lệ chuyên nghiệp hóa (ty_le_chuyen_nghiep): Lấy số dòng thuộc nhóm chia tổng dòng rồi nhân 100, làm tròn một chữ số thập phân.
  4. Ghép toàn bộ các chỉ số trên vào bảng ranh giới geo. Điền 0 cho các quận trắng dữ liệu (Pirque có tong_cho_o = 0, so_chuyen_nghiep = 0, ty_le_chuyen_nghiep = 0.0%).
  5. Trực quan hóa bản đồ phân bố tỷ lệ chuyên nghiệp hóa với dải màu tím Purples, ghi nhãn tên quận, tỷ lệ phần trăm và quy mô mẫu n lên tâm hình học của từng quận.
  6. Lưu tệp bản đồ ra đường dẫn path định dạng vector SVG.
  7. Cảnh báo phương pháp luận: Phân tích vì sao không được phép suy diễn quan hệ nhân quả (causality), mức độ an toàn (safety) hay mức độ ưa chuộng của khách hàng từ quy mô nguồn cung hay tỷ lệ chuyên nghiệp hóa.

Trường hợp biên cần chú ý: Phép chia tính tỷ lệ chuyên nghiệp hóa tại các quận có 0 chỗ ở sẽ phát sinh lỗi chia cho 0 (0 / 0 = NaN). Code dùng 0.0% làm quy ước hiển thị khi n bằng 0. Phải ghi rõ đây không phải một tỷ lệ quan sát được.

Lời giải

Đếm dòng chỗ ở, không đếm chủ nhà độc lập ​

Ngưỡng từ năm chỗ ở là quy ước phân nhóm cho bài này. Mỗi dòng được xếp theo calculated_host_listings_count, rồi ta đếm số dòng thuộc nhóm đó trong từng quận. Tỷ lệ thu được là phần niêm yết gắn với nhóm quy mô từ năm chỗ ở, không phải phần trăm số chủ nhà độc lập.

Với quận không có dòng, tỷ lệ có mẫu số bằng không nên chưa xác định. Code dùng 0.0 làm quy ước hiển thị và luôn phải đặt cạnh n bằng 0 để người đọc phân biệt với một quận có quan sát nhưng không có dòng đạt ngưỡng. Giá, số lượng và tỷ lệ nhóm chưa cho phép kết luận về an toàn, sự ưa chuộng, mức độ chuyên môn hay nguyên nhân định giá.

Cách 1: Tổng hợp đa hàm với pandas groupby kết hợp GeoPandas Plot ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import pandas as pd
from shapely.geometry import box
import geopandas as gpd


def analyze_spatial_concentration(
    ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str
) -> gpd.GeoDataFrame:
    """Phân tích cấu trúc nguồn cung và mức độ chuyên nghiệp hóa theo quận."""
    rows = []
    for ten_quan, nhom in ds.groupby("neighbourhood"):
        n = len(nhom)
        so_tu_5 = int((nhom["calculated_host_listings_count"] >= 5).sum())
        rows.append(
            {
                "neighbourhood": ten_quan,
                "tong_cho_o": n,
                "gia_trung_vi": nhom["price"].median(),
                "so_chuyen_nghiep": so_tu_5,
                "ty_le_chuyen_nghiep": round(so_tu_5 / n * 100, 1),
            }
        )
    grp = pd.DataFrame(rows)

    ban_do = geo.merge(grp, on="neighbourhood", how="left")
    ban_do["tong_cho_o"] = ban_do["tong_cho_o"].fillna(0).astype(int)
    ban_do["so_chuyen_nghiep"] = ban_do["so_chuyen_nghiep"].fillna(0).astype(int)
    ban_do["ty_le_chuyen_nghiep"] = ban_do["ty_le_chuyen_nghiep"].fillna(0.0)

    fig, ax = plt.subplots(figsize=(8, 6))
    ban_do.plot(
        column="ty_le_chuyen_nghiep",
        cmap="Purples",
        legend=True,
        edgecolor="#333",
        linewidth=0.8,
        ax=ax,
        legend_kwds={
            "label": "Tỷ lệ chỗ ở có chủ nhà từ 5 niêm yết (%)",
            "shrink": 0.75,
        },
    )
    ax.set_title(
        "Cơ cấu chỗ ở theo quy mô niêm yết của chủ nhà", fontsize=11, fontweight="bold"
    )
    ax.set_axis_off()

    for _, row in ban_do.iterrows():
        pt = row.geometry.centroid
        ax.text(
            pt.x,
            pt.y,
            f"{row['neighbourhood']}\n{row['ty_le_chuyen_nghiep']:.0f}%\n(n={row['tong_cho_o']})",
            ha="center",
            va="center",
            fontsize=8,
            color="#222",
        )

    fig.text(
        0.02,
        0.02,
        "Phân tích mở rộng: Nguồn cung và cơ cấu chủ nhà, không suy diễn chất lượng hay an toàn từ quy mô.",
        fontsize=8,
        color="#555",
    )

    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    plt.close(fig)
    return ban_do


# Dữ liệu thử nghiệm minh họa
_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)
demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
        "calculated_host_listings_count": [1 + (k * 5) % 9 for k in range(48)],
    }
)
o_luoi = [
    box(0, 1, 1, 2),
    box(1, 1, 2, 2),
    box(2, 1, 3, 2),
    box(0, 0, 1, 1),
    box(1, 0, 2, 1),
    box(2, 0, 3, 1),
]
geo_synth_demo = gpd.GeoDataFrame(
    {
        "neighbourhood": [
            "Santiago",
            "Providencia",
            "Las Condes",
            "Ñuñoa",
            "Lo Barnechea",
            "Pirque",
        ],
        "neighbourhood_group": [None] * 6,
    },
    geometry=o_luoi,
    crs="EPSG:3857",
)

duong_dan_ext = "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-06-mo-rong-khong-gian-da-chieu.svg"
ban_do_ext_c1 = analyze_spatial_concentration(demo_ds, geo_synth_demo, duong_dan_ext)
print(
    "Kết xuất bài mở rộng Cách 1 thành công. Cột dữ liệu:", list(ban_do_ext_c1.columns)
)
Giải thích chi tiết Cách 1 ​

Vòng lặp lấy từng nhóm quận rồi tính ba số: Số dòng, trung vị giá và số dòng có chủ nhà từ 5 niêm yết. len(nhom) đếm cả dòng thiếu id. .median() bỏ giá thiếu và phép cộng các cờ boolean đếm số dòng thỏa điều kiện. Mỗi kết quả được thêm vào danh sách rows, rồi chuyển thành .

Sau ghép vào ranh giới, số đếm của Pirque được điền không, còn giá trung vị giữ . Nhãn bản đồ ghi đồng thời tỷ lệ và n để không che mất nhóm nhỏ. Các phần trăm ở bảng dưới được làm tròn một chữ số, còn nhãn trong code làm tròn về số nguyên nên có thể khác mức hiển thị trong bảng.

Kết quả thực thi Cách 1 ​
QuậnnSố dòng có chủ nhà từ 5 chỗ ởTỷ lệ (%)Trung vị (CLP)
Santiago181055.659271.0
Providencia12650.067651.0
Las Condes8562.587029.5
Ñuñoa6350.072679.0
Lo Barnechea4250.063314.5
Pirque000.0 theo quy ước
Hình 13.6: Phân tích không gian đa thuộc tính kết hợp quy mô nguồn cung và tỷ lệ chuyên nghiệp hóa
Hình 13.6: Phân tích không gian đa thuộc tính kết hợp quy mô nguồn cung và tỷ lệ chuyên nghiệp hóa

Cách 2: Vector hóa tính toán ma trận phân nhóm kinh doanh ​

python
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import geopandas as gpd
from shapely.geometry import box


def analyze_spatial_concentration_fast(
    ds: pd.DataFrame, geo: gpd.GeoDataFrame, path: str
) -> gpd.GeoDataFrame:
    """Phân tích cấu trúc nguồn cung và mức độ chuyên nghiệp hóa theo quận."""
    ds_kieu = ds.assign(
        kieu_host=(ds["calculated_host_listings_count"] >= 5).map(
            {True: "chuyen_nghiep", False: "ca_nhan"}
        )
    )
    grp = (
        ds_kieu.groupby("neighbourhood")
        .agg(
            tong_cho_o=("id", "size"),
            gia_trung_vi=("price", "median"),
            so_chuyen_nghiep=("kieu_host", lambda s: (s == "chuyen_nghiep").sum()),
        )
        .reset_index()
    )
    grp["ty_le_chuyen_nghiep"] = (
        grp["so_chuyen_nghiep"] / grp["tong_cho_o"] * 100
    ).round(1)

    ban_do = geo.merge(grp, on="neighbourhood", how="left")
    ban_do["tong_cho_o"] = ban_do["tong_cho_o"].fillna(0).astype(int)
    ban_do["so_chuyen_nghiep"] = ban_do["so_chuyen_nghiep"].fillna(0).astype(int)
    ban_do["ty_le_chuyen_nghiep"] = ban_do["ty_le_chuyen_nghiep"].fillna(0.0)

    fig, ax = plt.subplots(figsize=(8, 6))
    ban_do.plot(
        column="ty_le_chuyen_nghiep",
        cmap="Purples",
        legend=True,
        edgecolor="#333",
        linewidth=0.8,
        ax=ax,
        legend_kwds={
            "label": "Tỷ lệ chỗ ở có chủ nhà từ 5 niêm yết (%)",
            "shrink": 0.75,
        },
    )
    ax.set_title(
        "Cơ cấu chỗ ở theo quy mô niêm yết của chủ nhà", fontsize=11, fontweight="bold"
    )
    ax.set_axis_off()

    for _, row in ban_do.iterrows():
        pt = row.geometry.centroid
        ax.text(
            pt.x,
            pt.y,
            f"{row['neighbourhood']}\n{row['ty_le_chuyen_nghiep']:.0f}%\n(n={row['tong_cho_o']})",
            ha="center",
            va="center",
            fontsize=8,
            color="#222",
        )

    fig.text(
        0.02,
        0.02,
        "Phân tích mở rộng: Nguồn cung và cơ cấu chủ nhà, không suy diễn chất lượng hay an toàn từ quy mô.",
        fontsize=8,
        color="#555",
    )

    Path(path).parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(path, format="svg", bbox_inches="tight")
    plt.close(fig)
    return ban_do


_QUAN = (
    ["Santiago"] * 18
    + ["Providencia"] * 12
    + ["Las Condes"] * 8
    + ["Ñuñoa"] * 6
    + ["Lo Barnechea"] * 4
)

demo_ds = pd.DataFrame(
    {
        "id": range(201, 249),
        "neighbourhood": _QUAN,
        "price": [
            (
                None
                if k % 11 == 5
                else float(18000 + (k * 7919) % 90000 + (15000 if k % 3 == 0 else 0))
            )
            for k in range(48)
        ],
        "calculated_host_listings_count": [1 + (k * 5) % 9 for k in range(48)],
    }
)

o_luoi = [
    box(0, 1, 1, 2),
    box(1, 1, 2, 2),
    box(2, 1, 3, 2),
    box(0, 0, 1, 1),
    box(1, 0, 2, 1),
    box(2, 0, 3, 1),
]

geo_synth_demo = gpd.GeoDataFrame(
    {
        "neighbourhood": [
            "Santiago",
            "Providencia",
            "Las Condes",
            "Ñuñoa",
            "Lo Barnechea",
            "Pirque",
        ],
        "neighbourhood_group": [None] * 6,
    },
    geometry=o_luoi,
    crs="EPSG:3857",
)

duong_dan_ext = "docs/xu-ly-du-lieu/bai-giang/img/labs/lab-13/hinh-13-06-mo-rong-khong-gian-da-chieu.svg"

ban_do_ext_c2 = analyze_spatial_concentration_fast(
    demo_ds, geo_synth_demo, duong_dan_ext
)
print(ban_do_ext_c2.drop(columns="geometry").to_string(index=False))


def phan_nhom_chien_luoc_khong_gian(ban_do_ext: gpd.GeoDataFrame) -> pd.DataFrame:
    """Phân loại các quận vào ma trận chiến lược nguồn cung và chuyên nghiệp hóa."""
    df_quan = ban_do_ext.dropna(subset=["gia_trung_vi"]).copy()
    nguong_n = df_quan["tong_cho_o"].median()
    nguong_pro = df_quan["ty_le_chuyen_nghiep"].median()

    dieu_kien = [
        (df_quan["tong_cho_o"] >= nguong_n)
        & (df_quan["ty_le_chuyen_nghiep"] >= nguong_pro),
        (df_quan["tong_cho_o"] >= nguong_n)
        & (df_quan["ty_le_chuyen_nghiep"] < nguong_pro),
        (df_quan["tong_cho_o"] < nguong_n)
        & (df_quan["ty_le_chuyen_nghiep"] >= nguong_pro),
        (df_quan["tong_cho_o"] < nguong_n)
        & (df_quan["ty_le_chuyen_nghiep"] < nguong_pro),
    ]
    nhan_nhom = [
        "Quy mô lớn - Tỷ lệ từ ngưỡng trở lên",
        "Quy mô lớn - Tỷ lệ dưới ngưỡng",
        "Quy mô nhỏ - Tỷ lệ từ ngưỡng trở lên",
        "Quy mô nhỏ - Tỷ lệ dưới ngưỡng",
    ]
    df_quan["nhom_chien_luoc"] = np.select(
        dieu_kien, nhan_nhom, default="Chưa xác định"
    )
    return df_quan[
        [
            "neighbourhood",
            "tong_cho_o",
            "ty_le_chuyen_nghiep",
            "gia_trung_vi",
            "nhom_chien_luoc",
        ]
    ]


df_chien_luoc = phan_nhom_chien_luoc_khong_gian(ban_do_ext_c2)
print("Bảng phân nhóm chiến lược không gian:")
print(df_chien_luoc)
Giải thích chi tiết Cách 2 ​

Hàm analyze_spatial_concentration_fast dùng named aggregation để tính cả ba chỉ số trong một phép nhóm. Phép size đếm dòng, median bỏ giá thiếu, còn hàm đếm cờ xác định số dòng đạt ngưỡng quy mô. Sau đó, ghép vào các đa giác và vẽ cùng chỉ số như Cách 1. Sau khi có bảng quận, phần phân loại là một bước mở rộng: Lấy trung vị của số dòng và trung vị của tỷ lệ nhóm trong các quận có giá làm hai ngưỡng rồi xét bốn tổ hợp cao/thấp.

np.select gán nhãn cho từng dòng theo các mặt nạ điều kiện. Ngưỡng phụ thuộc chính các quận được đưa vào, nên đây là một cách phân nhóm mô tả, không phải tiêu chuẩn nghề nghiệp hay khuyến nghị đầu tư. Với fixture, ngưỡng số dòng là 8 và ngưỡng tỷ lệ là 50.0. Dấu >= khiến các giá trị bằng ngưỡng thuộc phía cao.

Kết quả thực thi Cách 2 ​
QuậnnTỷ lệ nhóm từ 5 chỗ ở (%)Phân nhóm theo hai ngưỡng
Santiago1855.6Quy mô lớn, tỷ lệ từ ngưỡng trở lên
Providencia1250.0Quy mô lớn, tỷ lệ từ ngưỡng trở lên
Las Condes862.5Quy mô lớn, tỷ lệ từ ngưỡng trở lên
Ñuñoa650.0Quy mô nhỏ, tỷ lệ từ ngưỡng trở lên
Lo Barnechea450.0Quy mô nhỏ, tỷ lệ từ ngưỡng trở lên

Pirque có giá nên bị loại khỏi bước phân nhóm mở rộng. Không có quận nào ở phía tỷ lệ dưới 50.0 trong fixture này.

So sánh hai cách ​

Bảng và bản đồ trả lời quy mô, giá và cơ cấu của từng quận. Bước phân nhóm thêm một nhãn tóm tắt theo hai ngưỡng của mẫu. Nhãn không đưa thêm bằng chứng về đặc điểm đô thị, kiểu khách hoặc hiệu quả kinh doanh. Dùng np.select không tự chứng minh cách này nhanh hơn nếu chưa đo.


Tài liệu tham khảo ​

Tài liệu tham khảo ​

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