Hệ gợi ý anime theo nội dung
Hệ gợi ý anime dựng trên dữ liệu tự crawl bằng Selenium, vector hoá thể loại bằng Spark ML, phát lại lượt xem qua Kafka và demo bằng Streamlit.
- Python
- Streamlit
- Spark
- Kafka
Đồ án môn Big Data, làm nhóm hai người: một hệ gợi ý anime đi từ con số không — tự crawl dữ liệu, tự làm sạch, vector hoá bằng Spark ML rồi demo bằng Streamlit.
Tự crawl thay vì tải dataset có sẵn#
Trên Kaggle có sẵn nhiều dataset anime, nhưng chúng đều là tên tiếng Anh hoặc romaji.
Tôi muốn hệ thống trả về đúng cái tên người Việt hay gọi, nên chọn crawl thẳng từ
animevietsub. Selenium điều khiển một bản Brave chạy headless, duyệt qua hai loại danh
sách (list-le cho phim lẻ, list-bo cho phim bộ) nhân với bốn mùa, mỗi tổ hợp vài chục
trang. Từ mỗi thẻ phim tôi lấy tên, link, poster, số tập, đánh giá, lượt xem, chất lượng,
năm, thể loại và mô tả — tám file JSON, gộp lại thành 5.389 record.
Phần lớn thời gian nằm ở khâu làm sạch#
Dữ liệu crawl về không có trường nào dùng ngay được. Cột rating là nguyên một câu tiếng
Việt dạng "9.1 trong số 10 dựa trên 76 thành viên đánh giá", tôi dùng regex tách thành
rate kiểu số thực và nums_of_vote kiểu số nguyên. Cột quality có đủ biến thể
FHD, Full HD, BD, BD Vietsub, 480P — và một ô ghi nhầm thành một ngày tháng — nên phải
dựng từ điển ánh xạ về sáu nhóm. Cột year lẫn cả "07 - 2021" và "2010-2011". Riêng
episode thì mọi giá trị null đều rơi vào phim lẻ, nên điền 1 là hợp lý. Kết quả cuối
là 5.389 dòng, 14 cột, nạp vào MongoDB để app đọc lại.
Vector thể loại, cosine similarity tự viết#
Tôi chọn feature là genres chứ không phải description, vì mô tả trên site đều bị
cắt cụt bằng dấu ba chấm — TF-IDF trên đó sẽ học phần mở đầu chứ không học nội dung.
Thể loại thì ngắn, sạch và đủ để phân biệt. Vậy nên CountVectorizer của Spark ML biến
danh sách thể loại thành vector đếm, còn similarity thì tôi tự tính tay trên RDD:
data = data.withColumn("genres", split(col("genres"), ","))
# Model được lưu ra đĩa để consumer và app Streamlit nạp lại đúng một vocabulary
vectorizer = CountVectorizer(inputCol="genres", outputCol="genre_vector")
count_vectorizer_model = vectorizer.fit(data)
def calculate_similarity(row):
target_vector = broadcast_genre_vector.value
row_vector = DenseVector(row.genre_vector.toArray())
dot_product = sum(target_vector[i] * row_vector[i] for i in range(len(target_vector)))
norm_target = sum(x ** 2 for x in target_vector) ** 0.5
norm_row = sum(x ** 2 for x in row_vector) ** 0.5
return row.title, row.item_id, dot_product / (norm_target * norm_row)
# Quét toàn bộ catalogue cho mỗi câu hỏi rồi giữ lại top-N
similar_items = (preprocessed_data.rdd.map(calculate_similarity)
.filter(lambda x: x[1] != item_id)
.takeOrdered(top_n, key=lambda x: -x[2]))Kafka ở đây là mô phỏng, không phải stream thật#
Dữ liệu crawl về chỉ là một lát cắt tĩnh, muốn có "thời gian thực" thì phải tự tạo ra.
Tôi dựng một file lịch sử xem phim rồi cho producer bắn từng lượt xem vào topic anime,
mỗi record cách nhau năm giây; consumer nhận sự kiện, nạp lại CountVectorizerModel đã
lưu và in ra gợi ý ngay sau mỗi lượt xem.
producer = KafkaProducer(
bootstrap_servers='localhost:9092',
value_serializer=lambda v: json.dumps(v).encode('utf-8')
)
for _, row in df.iterrows():
producer.send('anime', value={
'user_id': row['user_id'],
'item_id': row['link'],
'titles_watched': row['titles_watched'],
'genres': row['genres'].split(','),
'rate': row['rate'],
})
time.sleep(5) # giãn nhịp cho giống luồng người dùng thậtDemo Streamlit#
App có hai tab. Tab gợi ý cho nhập một phần tên phim, khớp bằng LIKE %...% rồi hiển thị
top 10 kèm poster, link, danh sách thể loại và điểm similarity — để người xem tự đánh giá
gợi ý có hợp lý không thay vì phải tin vào một con số. Tab thống kê đọc thẳng từ Spark:
tổng số record, số tựa duy nhất, điểm trung bình và biểu đồ tần suất theo thể loại.
Kết quả#
- Pipeline chạy thông từ crawl đến demo: Selenium → JSON → CSV → MongoDB → Spark → Streamlit, mỗi bước là một notebook chạy lại được
- Precision@10 đo trong notebook đạt 0.75, nhưng tôi coi đây là con số tham khảo: tiêu chí "đúng" chỉ là trùng ít nhất một thể loại, vốn khá dễ thoả mãn
- Điểm yếu tôi nhìn thấy rõ: vector chỉ dựa trên thể loại nên rất nhiều phim đồng hạng
ở similarity gần 1.0, và việc tách string bằng
split(",")mà không trim khoảng trắng khiếnActionvàActionthành hai từ khác nhau trong vocabulary