简介:葡萄酒评论数据集是一份面向数据挖掘、机器学习和文本分析场景的公开语料,共含三个文件,整体压缩包约26.84MB,以CSV和JSON两种主流格式组织。数据汇集13万余条真实品鉴记录,字段覆盖品种、产地位置、酒庄名称、价格以及评论描述,既能支撑价格预测回归任务,也可用于产区和酒庄的聚类对比、评论文本的语义分析或关键词提取。两个CSV文件分别包含13万行和15万行结构化记录,列式规整,便于直接读入Pandas或Spark进行特征工程;JSON文件亦提供近7000个嵌套评论节点,适合练习JSON解析或作为文档型数据库的导入样例。整套数据字段语义明确、体量适中,从入门级图表展示到进阶模型训练皆可适用。资源目前已有102人学习下载,对于想快速获得干净、真实葡萄酒评论数据的研究者或开发者而言,是一份可直接取用的参考集。
1. 葡萄酒评论数据集CSV:为什么一套评语文本能同时喂给NLP和数据挖掘
130k条葡萄酒评论记录被拆成三个CSV文件,这事听起来像普通的归档整理,但对做数据分析的人来说,它其实是一份难得的“带主观文本 + 带结构化评分”的练手数据:每条记录里有品酒师写的评语、对应的点数评分、价格、产区、品种和酒庄信息。你既可以拿它练自然语言处理,把评语变成预测葡萄酒得分的特征,也可以拿它做探索性数据分析,看价格和评分到底是不是正相关。适合的人群很直接:刚学 pandas 想找一份真实数据练清洗的人、做文本特征工程缺标注数据的人、以及想找个中等规模数据集跑通分类或回归全流程的算法工程师。下面这套流程是我处理同类 CSV 数据集时常用的方案,参数和坑都按 130k 这个规模来设计。
2. 三文件结构先摸清:读取路径、编码与字段探查脚本
拿到三个 CSV 文件,第一反应别急着画图,先搞清楚三张表的角色。常见做法是三个文件分别存放主记录和补充维度表,比如一个文件存酒款基础信息,一个存评论正文和评分,一个存酒庄/产区映射。但也存在另一种可能——三个文件是按年份或地区切分的分片,合并方式完全不同。所以第一步永远是用脚本把 shape、列名、缺失率、唯一值数量打出来。
2.1 不急着分析,先把三张CSV的形状与缺失率盘出来
import pandas as pd from pathlib import Path data_dir = Path("./wine_reviews") files = list(data_dir.glob("*.csv")) print("找到文件:", [f.name for f in files]) for f in files: try: df = pd.read_csv(f, encoding="utf-8") except UnicodeDecodeError: df = pd.read_csv(f, encoding="latin1") print(f"\n=== {f.name} ===") print("shape:", df.shape) print("列名:", df.columns.tolist()) print("缺失率(前10列):") print((df.isna().mean().sort_values(ascending=False).head(10) * 100).round(2))逻辑说明:我刻意先尝试 utf-8 再回退 latin1,是因为很多葡萄酒数据集在生成时用的并不是标准 utf-8——评论里带重音符号的法语、西班牙语词,经常被 Excel 或旧系统存成 latin1。直接指定 utf-8 会在读取阶段抛 UnicodeDecodeError,整个流程当场卡死。这个回退写法比手动指定编码要稳,但注意它掩盖了编码问题,后面第 5 章我会讲怎么定位真实编码。
参数说明:glob("*.csv")只匹配当前目录下的 CSV,不递归子目录;如果你的文件在嵌套文件夹里,用rglob("*.csv")。df.isna().mean()返回每列缺失比例,sort_values(ascending=False).head(10)只显示缺失最严重的 10 列,避免 20 多列的输出刷屏。这里 130k 行数据用 pandas 默认的 C 引擎读取毫无压力,不要为了“大数据”的错觉去开 Dask 或 Spark,属于杀鸡用牛刀。
2.2 130k行不是大数据,但CSV的引号和换行会先给你上眼药
跑完上面的脚本,你会看到每个文件的行数。记住一个关键点:130k 行这个规模,瓶颈不在内存,而在 CSV 解析器的“脾气”。如果某个文件的行数跟你预期差很多,先别怀疑数据损坏,大概率是字段值里带了换行符或未转义的引号。
import csv with open("./wine_reviews/winemag_reviews.csv", encoding="utf-8", newline="") as fp: sample = list(csv.reader(fp, delimiter=","))[:200] # 找出列数不等于表头列数的行 header_cols = len(sample[0]) bad_rows = [i for i, row in enumerate(sample) if len(row) != header_cols] print(f"前200行中列数异常的行索引: {bad_rows[:10]}") # 看具体某行的原始内容 if bad_rows: idx = bad_rows[0] raw = open("./wine_reviews/winemag_reviews.csv", encoding="utf-8").readlines()[idx] print("原始行片段:", repr(raw[:500]))逻辑说明:csv.reader默认处理引号包裹的字段,如果字段内部有换行,它会把换行当作字段内容而不是行结束符,这时len(row)会正常等于表头列数。反过来,如果用readlines()按物理行读,再按逗号split,就会把一条评论拆成两行,行数虚增。我见过有人因为这个问题直接把 130k 数据误判成 160k 去汇报,属于典型的 CSV 翻车现场。
参数说明:newline=""是 Python 官方文档推荐的标准写法,避免 Windows 下\r\n被再次翻译;delimiter=","对标准 CSV 是默认值,但如果你遇到的是分号分隔的欧洲版数据,这里要改成delimiter=";"。这段代码的作用不是修复数据,而是帮你确认:文件本身的物理行数和逻辑记录数是否一致,这决定了后续能不能放心用 pandas 一把梭。
3. 把评论字段洗干净:换行、引号与多表合并的三个硬规则
探查清楚三张表的结构后,进入真正的体力活:清洗。葡萄酒评论数据集最脏的地方集中在两个区域——评论文本字段里混入的转义符号和空白,以及三张表关联时主键不唯一导致的合并爆炸。这两块处理不好,后续特征工程和建模全是幻觉。
3.1 评论文本清洗:去掉转义符、HTML实体与连续空白
import pandas as pd import html import re def clean_text(s: str) -> str: if pd.isna(s): return "" # 1. 还原常见HTML实体 s = html.unescape(str(s)) # 2. 去除换行/回车/tab等控制字符,统一成空格 s = re.sub(r"[\r\n\t\x00-\x1f]", " ", s) # 3. 将连续空白压成单个空格 s = re.sub(r"\s{2,}", " ", s) # 4. 去首尾空格 return s.strip() df["description_clean"] = df["description"].apply(clean_text) df["desc_len"] = df["description_clean"].str.len() df["desc_word_count"] = df["description_clean"].str.split().str.len()逻辑说明:html.unescape处理&和'这类实体,在爬虫抓取的数据里特别常见,Wine 类数据如果经历过网页转存,几乎必中。re.sub(r"[\r\n\t\x00-\x1f]", " ", s)把 ASCII 控制字符全部转成空格,避免后面做词频统计时出现孤立换行符把分词器搞崩溃。最后生成两个新特征:字符长度和单词数。这两个特征在后面的评分预测里非常有解释力——很多品酒师的评分习惯是“描述越具体分数越高”,词数本身就能当一个弱特征。
参数说明:pd.isna(s)保证了评论为空时返回空字符串而不是 None,否则.apply会报错。\x00-\x1f覆盖了从 NUL 到 US 的所有控制字符,比单独写\n\r\t干净得多。\s{2,}用贪婪匹配把两个及以上空白压缩为一个,这一步做完文本才适合进入 TF-IDF 或词向量阶段——否则同一个词因为前后空格不同会被分出多个词形。
3.2 按酒庄/产区做表连接:先查唯一性再 merge,顺序不能错
# 假设 df_main 是主表, df_winery 是酒庄维表 # 第一步:检查维表主键是否唯一 print("winery列重复值数量:", df_winery["winery"].duplicated().sum()) # 如果不唯一,看重复的是哪些 if df_winery["winery"].duplicated().sum() > 0: dup_names = df_winery.loc[df_winery["winery"].duplicated(), "winery"].unique() print("重复酒庄示例:", dup_names[:5]) # 常见方案:用 酒庄+产区 作为复合键 print("复合键重复数:", df_winery.duplicated(subset=["winery", "province"]).sum()) # 合并:主表左连接维表 df_merged = df_main.merge( df_winery, on=["winery", "province"], how="left", validate="many_to_one" ) print("合并后行数:", len(df_merged), "原主表行数:", len(df_main))逻辑说明:这是三个文件合并时最容易翻车的环节。如果维表的winery列根本不唯一,直接df_main.merge(df_winery, on="winery")会产生笛卡尔积——一行评论对应多条酒庄信息,130k 行直接膨胀到 200k+,而你根本察觉不到错误,因为 merge 不会报错。所以我在合并且前强制打印duplicated().sum()。如果单键不唯一,一个常用补救方案是引入第二列组成复合键,比如酒庄加省份。
参数说明:validate="many_to_one"不是摆设,它让 pandas 在合并前检查右表键的唯一性。如果右表有重复,直接抛MergeError,相当于给合并操作上了保险丝。how="left"保证主表行数不丢失,注释里len(df_merged)和len(df_main)必须相等,不相等说明左表自身主键有重复,要去查主表是不是同一瓶酒被录了两遍。
4. 特征工程到第一版模型:从文本到评分预测的完整闭环
清洗完成、表也合并好了,现在数据里有干净的评论文本、价格、产区、品种、酒庄,以及我们要预测的目标——points评分。这一段我按“特征怎么造 → 模型怎么训 → 参数怎么调”拆开讲,每步都给出能直接跑的结果。
4.1 把文本变特征:词数、情感分与酒款复杂度
from sklearn.feature_extraction.text import TfidfVectorizer import numpy as np # 基于清洗后的文本做TF-IDF,限制特征数量防止维度爆炸 vec = TfidfVectorizer( max_features=3000, ngram_range=(1, 2), stop_words="english", min_df=5, max_df=0.8 ) tfidf = vec.fit_transform(df["description_clean"]) print("TF-IDF矩阵形状:", tfidf.shape) # 简单的“复杂度”特征:描述里提到的葡萄品种个数(字符串匹配) grape_list = ["cabernet", "chardonnay", "pinot", "merlot", "syrah", "riesling"] df["grape_count"] = df["description_clean"].str.lower().apply( lambda x: sum(1 for g in grape_list if g in x) ) # 文本长度与价格组合成比率特征,注意price为0或空时的处理 df["price_safe"] = df["price"].replace(0, np.nan) df["desc_len_per_price"] = df["desc_len"] / df["price_safe"]逻辑说明:TF-IDF 把每条评语变成 3000 维的稀疏向量,ngram_range=(1,2)让模型能看到“black fruit”这种双词组合,对葡萄酒评论这种形容词密集的文本提升明显。min_df=5过滤掉只在少数几条记录里出现的生僻词,max_df=0.8过滤掉超过 80% 文档都出现的词(比如 “wine” 本身),这两刀砍完特征量会下降不少,模型也更稳。grape_count是一个不需要训练的特征,描述里提到的品种越多,通常代表这款酒越复杂——这是我常用的一个手动特征。
参数说明:TF-IDF 矩阵是稀疏的,千万不要toarray()转成稠密,130k 行 × 3000 维稠密矩阵是 390M 个浮点数,内存直接爆。price_safe用replace(0, np.nan)是因为 CSV 里价格可能是 0 或空字符串,先转成 NaN,后续模型会自动跳过缺失样本。
4.2 训练集/验证集切分与基线模型的三个必调参数
from sklearn.ensemble import RandomForestRegressor from sklearn.model_selection import train_test_split from sklearn.metrics import mean_absolute_error from sklearn.pipeline import make_union from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.compose import ColumnTransformer # 评分目标:先转成数值,并丢弃异常值 df["points"] = pd.to_numeric(df["points"], errors="coerce") df_model = df.dropna(subset=["points"]).copy() # 特征集合:文本 + 数值特征 feature_cols = ["price_safe", "desc_len", "desc_word_count", "grape_count"] X_numeric = df_model[feature_cols].fillna(-1) # 缺失用-1填充,树模型可容忍 X_train_text, X_test_text, y_train, y_test = train_test_split( df_model["description_clean"], df_model["points"], test_size=0.2, random_state=42 ) # 简化版:只用数值特征做基线 X_train_num, X_test_num = train_test_split( X_numeric, test_size=0.2, random_state=42 ) rf = RandomForestRegressor( n_estimators=300, max_depth=20, min_samples_leaf=3, n_jobs=-1, random_state=42 ) rf.fit(X_train_num, y_train) y_pred = rf.predict(X_test_num) print("基线MAE:", mean_absolute_error(y_test, y_pred))逻辑说明:pd.to_numeric(errors="coerce")把points里像 “90-91” 或空字符串这种脏值转成 NaN,再dropna丢掉。我没有直接用原始 X 做训练,而是先临时切分文本和数值,是因为如果你先填充缺失再切分,验证集里会出现训练集统计信息的影子——虽然树模型不受填充值影响,但为了养成好习惯,一切特征工程都应在切分后再做。fillna(-1)是树模型友好的缺失编码方式,千万别给随机森林做均值填充,它自己就能处理分裂时的不纯度。
参数说明:三个必调参数。n_estimators从 100 到 300 提升明显,超过 300 边际收益递减,训练时间却线性上涨。max_depth建议开在 15-25,没有它随机森林每棵树会无限生长,130k 数据上很容易过拟合。min_samples_leaf=3强制每个叶子至少 3 个样本,MAE 通常能比默认值降 0.1-0.2 分,代价是训练变慢几分钟,但值得。n_jobs=-1让所有 CPU 核心并行,四核机器上速度接近四倍。
5. 避坑专栏:处理这套CSV数据集最常见的5个翻车点
这套数据的坑不是隐藏的,而是披着“正常数据”的外衣潜伏着。下面 5 条全是我处理类似评论文本数据时的血泪经验,每条按现象、原因、解决的顺序写,可以直接对照排查。
5.1 读取时所有重音字符变成乱码
现象:打开 CSV 或 pandas 读入后,café变成café,法语产区名全是乱码。原因:文件实际是 UTF-8 编码,但被某些工具以 latin1 解读后存成了双字节乱码,或者反过来——文件是 latin1,你以 utf-8 读取抛异常。解决:不要用我第 2 章的“回退读取法”蒙混过关,用chardet或charset_normalizer检测真实编码,再统一转成 utf-8 输出。一步到位的写法是:
from charset_normalizer import from_path best = from_path("./wine_reviews/winemag_reviews.csv").best() df = pd.read_csv("./wine_reviews/winemag_reviews.csv", encoding=best.encoding)5.2 评论字段里的换行导致 merge 后行数对不上
现象:三个文件单独看行数都正常,一旦 merge,行数要么暴增要么骤减,且duplicated()检查主键并没有重复。原因:CSV 中 description 字段内包含换行符,如果之前有人用 Excel 另存或 Python 的错误方式读取后再导出,会把一条记录拆成两条;反过来某些行被吃掉。解决:永远用csv.reader或 pandas 的 C 引擎读取,不要用open().readlines()按行分割再处理;导出时统一加lineterminator="\n"和quoting=csv.QUOTE_ALL,确保写入时带引号的字段被正确包裹。
5.3 points 字段读取后类型是 object 而不是 int
现象:df["points"].dtype返回object,平均值算不了,绘图也报错。原因:字段里混入了类似"90-91"、"N/A"、"90+"这类非纯数字内容,只要有一个脏值,pandas 就把整列降级为字符串。解决:先看脏值长什么样再决定策略:
import pandas as pd bad_mask = df["points"].astype(str).str.contains(r"[^0-9.]", regex=True, na=False) print(df.loc[bad_mask, "points"].value_counts())如果脏值是区间90-91,我一般取均值 90.5 或用下界;如果是N/A直接dropna。千万别盲目pd.to_numeric(errors="coerce"),否则脏值会悄悄变成 NaN,你以为是清洗了,其实是把数据丢了一半。
5.4 用描述文本做特征时,验证集MAE极低但真实场景完全失效
现象:随机森林在测试集上 MAE 只有 1.5 分,但拿新的葡萄酒评论去预测,误差直接翻倍。原因:数据泄漏——description里可能包含了价格、产区、酒庄等可被模型直接“抄作业”的字段,或者更隐蔽的,你在切分前对整个数据集做了 TF-IDF 拟合,验证集已经参与了词表构建。解决:用Pipeline把 TF-IDF 和模型包在一起,确保fit只在训练集上执行:
from sklearn.pipeline import Pipeline from sklearn.ensemble import RandomForestRegressor from sklearn.feature_extraction.text import TfidfVectorizer pipe = Pipeline([ ("tfidf", TfidfVectorizer(max_features=3000, ngram_range=(1,2))), ("rf", RandomForestRegressor(n_estimators=300, max_depth=20, n_jobs=-1)) ]) pipe.fit(X_train_text, y_train)5.5 三文件按年份切分时,年份列重复且主键失效
现象:三个文件分别覆盖 2015、2016、2017 年的评论,但合并后同一瓶酒出现多条记录,winery + province复合键也压不住。原因:这种分片文件的真正主键可能是winery + province + vintage(年份),你只用了前两者。解决:合并前打印三个文件各自的列集合,找出真正能唯一标识记录的列组合。如果单一维度都无法唯一,用df.groupby(cols).size().sort_values()检查最大分组数量,分组大小等于 1 的组合才是合法主键。
6. 验证与进阶:用留存挑战集检验你的葡萄酒评分预测
模型训完不是终点,我要额外留一份模型从未见过的数据做“挑战集”——做法是切分时单独切出 10% 样本,连同清洗和特征工程一起封存在 pickle 里,只有最终评估时才解开。这样能把数据泄漏的风险降到最低,验证结果也更有说服力。具体验证指标除了前面用的 MAE,我还会看两个东西:预测误差在 88-92 分区间内的分布,以及模型的偏差方向。
import pickle import numpy as np from sklearn.metrics import mean_absolute_error # 解开封存挑战集 with open("holdout.pkl", "rb") as fp: X_hold, y_hold = pickle.load(fp) # 加载训练好的pipeline with open("final_model.pkl", "rb") as fp: final_pipe = pickle.load(fp) pred = final_pipe.predict(X_hold) mae = mean_absolute_error(y_hold, pred) print(f"挑战集MAE: {mae:.3f}") # 检查误差是否有偏:正值代表预测偏高 bias = np.mean(pred - y_hold) print(f"平均偏差: {bias:+.3f} 分") # 分段观察:低分酒和高分酒哪个预测更准 for lo, hi in [(80, 86), (86, 90), (90, 96)]: mask = (y_hold >= lo) & (y_hold < hi) seg_mae = mean_absolute_error(y_hold[mask], pred[mask]) print(f"{lo}-{hi}分区间 MAE: {seg_mae:.2f} (样本量 {mask.sum()})")逻辑说明:平均偏差是 + 0.3 分说明模型系统性地把酒评得偏高,常见原因是训练集中高分段样本占比过多,模型学会了“往高了猜”的倾向。分段 MAE 能告诉你模型到底在哪个区间失效——如果 90 分以上区间 MAE 突然变大,说明稀有高分样本不足,这时不要盲目加模型复杂度,而是去收集更多高分评论或者用class_weight给极端分数加权。
参数说明:pickle.dump存模型和挑战集时,务必用同版本 Python 和 sklearn 序列化,跨版本加载轻则告警,重则报ModuleNotFoundError。我习惯把特征列名、清洗函数源码、模型版本号一起打包进一个meta.json,这样三个月后回来看还能想起当初是怎么处理的。进阶方向再往下走,可以尝试用回归模型输出的残差做二分类,判断“一款酒是否值这个价”,或者把description用预训练 embedding 代替 TF-IDF,看对 MAE 的影响有多大——但基线模型一定要留好,方便反复对照。我的个人习惯是拿到任何新的 CSV 数据集,第一件事永远是跑第 2 章那个探查脚本,把 shape、缺失率、脏值分布打印出来贴在笔记里,之后再决定建模路线,这套工序帮我避开过很多想当然的弯路,希望帮到你。
本文还有配套的精品资源,点击获取