用pandas+LightGBM实战葡萄酒评论数据集:从CSV清洗到评分预测
简介一份面向数据挖掘、文本分析与统计学习者的葡萄酒评论数据集约130k条真实评论记录覆盖品种、产地位置、酒庄、价格及评酒师描述等核心字段适合用于情感分析、价格回归、品类聚类等练习或课题研究。资源共3个文件其中两个CSV分别包含不同历史批次的评论数据便于做对比验证另有一个JSON版本适合程序化读取或嵌套结构处理。压缩包约26.84MB整体体量轻量上手门槛低。已有102人学习下载。读者可通过该数据集快速开展数据清洗、特征提取、可视化探索等完整流程实践也可参考其列级元数据与统计摘要理解葡萄酒评论场景下的字段设计逻辑。1. 葡萄酒评论数据集3文件130k记录CSV这份数据到底能做什么第一次拿到「葡萄酒评论数据集3文件130k记录CSV」时我先被130k这个数字带偏了以为随便跑个模型就能出结论。三张CSV合并完、清洗完真正能落进回归模型的行不到一半。这份数据有意思的地方在于它同时包含自由文本评酒师的品鉴描述和结构化字段国家、品种、价格、评分适合完整走一遍从CSV读取、多表合并、文本特征工程到评分预测的链路。适合三类人刚把pandas学完的入门者、要练文本特征的NLP新手、做推荐系统冷启动时缺一份带叙事文本的评分数据的人。想直接拿它练手可以但得先接受“数据不干净”这个前提。2. 先摸清三个CSV的家底表结构、行列规模与字段语义拿到手里三个文件不管有没有字段字典我都先按三张独立表去摸。摸的顺序固定shape、dtype、缺失率、唯一键。前十分钟只做这些不做任何建模。很多人拿到CSV先df.head()看几行然后急着画图结果后面全在跟数据质量缠斗。2.1 用pandas读取csv文件核对shape、dtype、缺失率先用pandas读取csv文件把三张表都读进来。我一般不逐个print而是写一个循环统一输出三个关键数字。import pandas as pd df_reviews pd.read_csv(data/wine_reviews.csv, low_memoryFalse) df_wineries pd.read_csv(data/wineries.csv) df_regions pd.read_csv(data/regions.csv) for name, df in [ (reviews, df_reviews), (wineries, df_wineries), (regions, df_regions), ]: print(f[{name}] shape{df.shape}) print(df.dtypes.value_counts()) print(df.isna().mean().sort_values(ascendingFalse).head(3))逻辑说明shape告诉你每个文件的行列规模130k这个数字是否真实分布在主表上一眼就能确认。dtypes.value_counts()把字段类型汇总成计数如果object类型占绝大多数说明这个表偏文本如果float/int占多数说明偏数值。缺失率用isna().mean()按列算输出的是每个字段的缺失比例排在前三的就是接下来要重点处理的列。参数说明low_memoryFalse是读大CSV时的常用开关。默认分块读取会导致同列类型推断不一致打开后pandas会读完整文件再统一推断类型。代价是内存占用高一些对130k行×十几列完全不是问题如果机器只有8G内存建议加usecols只读需要的列。这一步常见的误用是只print(df.info())就开跑。info能看到非空计数但看不到缺失比例130k行里缺5%和缺50%的处理策略完全不同。缺5%可以dropna缺50%就得认真想填充方案或直接丢弃该列。2.2 三张表怎么关联主键、外键与合并策略三张CSV的“三文件”身份通常不是并行的三份宽表而是评论主表加维度表。我按最常见的结构来拆解也就是评论事实表、酒庄信息表、产区维度表。字段不一定完全一致但语义大概率落在下表这个范围。文件常见字段行数特征键wine_reviews.csvid, country, province, points, price, description, variety约130kid 为主键wineries.csvwinery_id, winery_name, review_id可能因酒庄多次出现而重复review_id 关联评论regions.csvregion_id, region_name, province, country几十到几百行provincecountry 自然键合并时最怕的是多对多。reviews和wineries如果一对多比如同一review_id出现多行直接merge会让行数膨胀到130k以上。所以我的顺序是先去重、再合并、再加validate校验。df_merge df_reviews[[id, points, price, description, country, province]].copy() df_wineries_dedup ( df_wineries.sort_values(winery_id) .drop_duplicates(review_id, keepfirst) ) df_all df_merge.merge( df_wineries_dedup, onreview_id, howleft, validateone_to_one ) df_all df_all.merge( df_regions, on[province, country], howleft, validatemany_to_one ) print(df_all.shape)逻辑说明merge前先sort_values再drop_duplicates是为了让保留的那条记录是确定的而不是随机抽样。validateone_to_one会让pandas在合并时检查review_id是否唯一如果wineries表里有重复review_id这里直接抛异常就不会带着错误悄悄往下走。第二个merge用many_to_one要求regions里的provincecountry组合唯一如果province跨country重名这里也会暴露。参数说明howleft保左表全集也就是评论行不会因为维度表缺失而丢失。validate三个可选值one_to_one、one_to_many、many_to_one按实际数据关系选选错会立刻报错这正是我们想要的“后悔药”——比跑完模型才发现行数不对强得多。怎么把多个csv格式的文件合并在一起网上方案很多concat、merge、join都有。但三张表有业务含义关联时不要用concat堆行要用merge按键关联。concat只适合同构文件追加。2.3 130k条记录的数据分布先看分布再决定建模方式合并完先看关键字段的分布这一步决定后面特征工程怎么做。import numpy as np print(df_all[points].describe()) print(df_all[points].value_counts().sort_index().tail(10)) print(df_all[country].value_counts().head(10)) df_all[price_log] np.log1p(df_all[price]) print(df_all[price].describe())逻辑说明评分字段points的count、min、max先过一遍。葡萄酒评论数据集的评分区间通常集中在80到100之间如果min低于80说明混入了非标准评分记录value_counts().sort_index().tail(10)看最高分档位的样本量高分样本太少的话预测高分酒会非常困难。price的分布通常严重右偏大部分酒在20到60美元少数上千直接建模会让高价位样本主导梯度log1p一下让分布更接近正态。参数说明np.log1p(x)等价于log(1x)好处是price为0时不会出现负无穷依然能得到0。describe输出五数概括加均值、标准差主要看min、max和分位数有没有明显异常值。country的value_counts会让你一眼看到数据来源集中度如果美国占一半后面做国别分类时要注意样本不平衡。这一步不要跳过。我见过有人直接拿description跑TF-IDF跑了半小时发现特征矩阵里一半是“wine”和“flavor”这种词没有区分度根子在分布没看。先看分布再建模是成本最低的一次纠偏。3. 把自由文本变成可建模字段评论清洗与特征工程的三个步骤三个CSV里最有信息量的列是description评酒师用一两句话描述颜色、香气、口感和结论。这段文本既是宝藏也是坑直接扔给TF-IDF能拿到一点信号但先做清洗和统计特征往往比盲目堆向量化更稳。3.1 评论长度与基础文本统计先不上向量化向量化之前先给每段评论算几个标量它们计算量小对评分预测却有可解释的贡献。import re def text_stats(s): if not isinstance(s, str): return {chars: 0, words: 0, caps: 0, digits: 0} words re.findall(r\b\w\b, s) return { chars: len(s), words: len(words), caps: sum(1 for w in words if w[:1].isupper()), digits: sum(1 for w in words if w.isdigit()), } stats df_all[description].apply(text_stats) df_all[desc_chars] stats.apply(lambda x: x[chars]) df_all[word_count] stats.apply(lambda x: x[words]) df_all[caps_ratio] stats.apply(lambda x: x[caps] / max(x[words], 1)) df_all[num_ratio] stats.apply(lambda x: x[digits] / max(x[words], 1)) print(df_all[[desc_chars, word_count, caps_ratio]].describe())逻辑说明desc_chars和word_count同理不同义字符数包含标点密度信息单词数更接近语义密度。caps_ratio是大写单词占比正常评论里句首大写占比很低如果某条评论这个词比例超过0.5大概率是全大写营销文案或数据粘贴错误。num_ratio同理评酒师文本里数字本来就少高数字占比的评论值得单独查。参数说明re.findall(r\b\w\b, s)按单词边界切比s.split()稳能够正确处理dont这类带撇号的词。缺失description的时候函数返回全0后续处理和建模不会报错。这里有个容易被忽略的点用apply而不是循环是因为apply在pandas里走C循环速度比Python循环快一个量级。很多人一上来就把description塞进TfidfVectorizer然后惊讶于模型效果没有提升。原因就是没做这些基础统计。长度本身就是一种质量信号评论越长的酒通常评分越高这个规律在130k条数据上相当明显。3.2 品种与酒庄名称标准化别名合并是常见的坑品种列variety看起来是干净的枚举值实际上会有大量缩写和别名。Cabernet Sauvignon可能写成Cab、Cabernet、Cabernet Sauvignon。不归一化直接做分类或one-hot等于把同一个品种拆成了三列模型要多学一倍的参数还学不到它们其实是同一个东西。variety_alias { cab: cabernet sauvignon, cabernet: cabernet sauvignon, cabernet sauvignon: cabernet sauvignon, chard: chardonnay, chardonnay: chardonnay, pinot: pinot noir, pinot noir: pinot noir, shiraz: syrah, syrah: syrah, } def norm_variety(v): if not isinstance(v, str): return unknown return variety_alias.get(v.strip().lower(), v.strip().lower()) df_all[variety_norm] df_all[variety].apply(norm_variety) vc df_all[variety_norm].value_counts() rare vc[vc 50].index df_all[variety_norm] df_all[variety_norm].replace(rare, other) print(df_all[variety_norm].value_counts().head(15))逻辑说明映射表先做精确别名再用strip和lower去掉大小写和首尾空格。这里不要用模糊匹配因为cabernet同时是cabernet sauvignon和cabernet franc的缩写模糊匹配会引入更多错误。低频合并为other是为了保证训练集和未来测试集的类别空间一致避免新数据出现模型没见过的品种。参数说明threshold取50是基于130k行的规模低频品种合计占比通常不到2%合并成other不会伤预测能力如果数据集小到几千行threshold降到10更合适。replace(rare, other)传入的是一个Index对象pandas会按原值精确匹配替换。这种别名问题在酒庄名winery上更严重同一个酒庄可能有Chateau X、Château X、Ch.X三种写法。处理思路一样先收集高频差异再人工映射。不要指望正则一次解决也别直接按文本聚类酒庄名的聚类基本是玄学。3.3 价格、品种与评分的交叉特征构造领域特征单列特征之外交叉特征往往更值钱。评酒师的评分有很强的“品种内比较”倾向同一款黑皮诺的90分和一款餐酒的90分含义不同。把绝对分数转成相对分模型会更好学。df_all[price_bucket] pd.cut( df_all[price], bins[0, 15, 30, 60, 120, np.inf], labels[budget, mid, premium, lux, ultra], ) variety_mean df_all.groupby(variety_norm)[points].transform(mean) df_all[points_above_variety] df_all[points] - variety_mean print(df_all[[word_count, price, points_above_variety]].corr())逻辑说明price_bucket把连续价格变成有序分箱对树模型没太大增益但对后续做分组统计和可视化很有用。points_above_variety是重点它等于“这款酒在自己的品种基准线之上多少分”这个值的均值为0、有正有负模型不用再隐式学习品种基准差异。corr()输出三列的皮尔逊相关系数用来粗看看word_count和points是不是正相关以及价格和变异分数的关系。参数说明pd.cut的bins最后一项写np.inf兜底任何价格都有归属不会产生NaNlabels要跟bins数量对应少一个都会报错。transform(mean)和groupby后直接mean的区别是transform不缩行数返回结果能直接赋回原DataFrame这是构造去均值特征的标准写法。这里不需要让所有特征都进模型先构造出来后面用特征重要性筛选。我一般会把这些交叉特征和原始列都留下来放到一个干净的df里后面建模按需选择。4. 用评论预测评分TF-IDF LightGBM的完整跑通这一章从文本向量化到模型训练到评估走完一条能跑的基线。前面产出的df_all已经包含price_log、variety_norm、points_above_variety等字段接下来直接复用它们。目标是弄清楚给定一段评论描述模型预测的评分能不能落到±2分以内。4.1 TF-IDF向量化ngram_range与max_features的取舍文本不是数值先要变成矩阵。TfidfVectorizer是最常用的baseline参数就三个最要紧min_df、max_df、ngram_range。from sklearn.feature_extraction.text import TfidfVectorizer from sklearn.model_selection import train_test_split train, test train_test_split(df_all, test_size0.2, random_state42) vectorizer TfidfVectorizer( min_df10, max_df0.7, ngram_range(1, 2), max_features20000, sublinear_tfTrue, ) Xtr_tfidf vectorizer.fit_transform(train[description].fillna()) Xte_tfidf vectorizer.transform(test[description].fillna()) print(Xtr_tfidf.shape)逻辑说明min_df10过滤掉只在不到10条评论里出现的词这些词在130k规模下基本是拼写错误或生僻表达留着只会撑大矩阵。max_df0.7过滤掉出现在70%以上评论里的词wine、flavor这类词没有区分度。ngram_range(1, 2)同时考虑单词和相邻双词比如black cherry作为整体才有风味含义单拆成black和cherry就损失了结构。参数说明max_features20000是硬截断按全局词频从高到低保留前2万个。这个值对130k行文本任务来说够用训练时间在单机上也能接受如果内存吃紧降到10000效果最多掉一点点。sublinear_tfTrue让词频做log缩放避免一个词在一篇长评论里出现20次就把权重顶上天。要注意的是一定要先train_test_split再做fit_transform。如果对整个数据集fitTF-IDF的词表和idf权重等于已经偷看了测试集验证指标的参考价值会打折扣。fit_transform只用在训练集transform用在测试集这一步错过就是泄漏。4.2 LightGBM回归关键参数与早停TF-IDF生成的是稀疏矩阵直接丢给LightGBM完全没问题。为了让模型同时吃到文本和结构化特征用scipy.sparse.hstack把两边拼起来。import lightgbm as lgb from scipy.sparse import hstack feat_cols [price_log, word_count, caps_ratio, points_above_variety] Xtr_all hstack([Xtr_tfidf, train[feat_cols].values]) Xte_all hstack([Xte_tfidf, test[feat_cols].values]) y_train train[points].astype(float) y_test test[points].astype(float) params { objective: regression, metric: mae, learning_rate: 0.05, num_leaves: 63, max_depth: 7, min_child_samples: 20, feature_fraction: 0.8, bagging_fraction: 0.8, bagging_freq: 1, verbosity: -1, seed: 42, } model lgb.train( params, lgb.Dataset(Xtr_all, labely_train), num_boost_round3000, valid_sets[lgb.Dataset(Xte_all, labely_test)], callbacks[lgb.early_stopping(100), lgb.log_evaluation(100)], )逻辑说明early_stopping(100)表示验证集MAE连续100轮没有刷新最低值时停止训练并把best_iteration记录下来。有了它num_boost_round3000只是上限实际迭代次数由早停决定。num_leaves63给了单棵树足够的叶子数但必须配合max_depth7防止树过深导致的结构性过拟合。feature_fraction和bagging_fraction都是随机采样一个按列采样、一个按行采样两者叠加能让集成模型更稳。参数说明learning_rate0.05是从0.01到0.1之间最常用的折中。0.01更稳但要更多轮0.1太快容易欠拟合。min_child_samples20要求每个叶子至少20个样本避免叶子节点学到极端个案。metricmae对应回归任务的平均绝对误差比mse更直观因为评分的业务单位就是“分”。verbosity-1关掉训练日志早停日志由log_evaluation(100)每100轮打印一次。在稀疏特征拼接这里还要提醒一点hstack要求所有输入是矩阵或数组train[feat_cols].values先转成numpy数组再拼否则会报错。拼完后的矩阵不保存成稠密数组稀疏格式占内存小得多。4.3 结果度量MAE和R²的合理范围跑完模型先看两个指标再加一个残差分布检查。from sklearn.metrics import mean_absolute_error, r2_score pred model.predict(Xte_all, num_iterationmodel.best_iteration) mae mean_absolute_error(y_test, pred) r2 r2_score(y_test, pred) resid pred - y_test print(fMAE{mae:.2f} R2{r2:.3f}) print(pd.Series(resid).describe())逻辑说明MAE能到2.0以内意思是预测的平均偏差不超过两个评分点。葡萄酒评分本身带主观性同一个酒在不同评酒师手上差2分很正常所以MAE在1.5到2.0之间就是能落地的模型。R²在0.4到0.5算是这个任务的不错水平它不是分类准确率0.4以上已经说明文本和价格特征解释了一小半的评分方差。参数说明predict时显式传num_iterationmodel.best_iteration避免模型用最后的可能已经过拟合的迭代树输出。resid的std如果和MAE差不量级说明误差分布稳定如果resid的min和max差异悬殊去看最差预测对应的文本很多时候不是模型问题而是那条评论本身是异常数据。这个基线模型不需要调参就能跑出一个可用的起点。想继续提升方向不是堆参数而是去处理不平衡的品种和国别样本或者用评酒师ID做分组特征。模型是个黑匣子但特征和数据处理不是。5. 避坑130k条CSV数据最常见的5个翻车现场这一章是血泪经验汇总。每一条都是我在这份葡萄酒评论数据集上踩过或看别人踩过的坑按“现象→原因→解决”写可以直接当checklist用。5.1 read_csv报ParserError引号没闭合现象pd.read_csv(wine_reviews.csv)抛错ParserError: Error tokenizing data. C error: Expected 6 fields in line 384, saw 7。原因CSV里评论文本含有未转义的双引号例如描述里出现He said its good导出方没有把内部引号翻倍pandas解析时把后面一行并了进来字段数就乱了。解决df_reviews pd.read_csv( wine_reviews.csv, enginepython, on_bad_linesskip, quoting1, ) print(df_reviews.shape)逻辑说明enginepython对不规则引号更容忍速度慢一点但能读到更多行on_bad_linesskip跳过坏行而不是中断quoting1让pandas用双引号作为字段定界符减少单引号干扰。跳过之后对比一下shape如果只少了不到0.1%的行可以接受如果少了上千行说明CSV生成端有bug要回去修导出逻辑。5.2 评分列看着是数字一排序就翻车现象data[points].max()返回99而不是99sort_values排序后88排在9前面。原因CSV里该列混入了空值和个别文本标记pandas整体推断成object类型字符串排序走字典序。解决df[points] pd.to_numeric(df[points], errorscoerce) loss df[points].isna().sum() df df.dropna(subset[points]) print(fdropped {loss} rows, dtype{df[points].dtype})逻辑说明errorscoerce把不能转数字的值置为NaN再dropna。关键是要把drop的行数print出来确认如果超过5%问题不是类型转换而是上游数据抽取有缺陷。丢掉的行若恰好集中在低分或某个特定来源会直接影响模型对低分段的预测。5.3 同一款酒被评论多次重复行让验证集失真现象模型MAE刷到1.0你以为很强上线后发现真实误差到3.0。原因同一酒庄同一年份同一品种经常被多条评论覆盖以及数据集本身可能有重复导出行。重复样本同时出现在训练集和测试集时模型在测试集上相当于“偷看答案”。解决df_all df_all.drop_duplicates( subset[winery_name, variety_norm, price, description], keepfirst, ) print(df_all.shape)逻辑说明用酒庄品种价格描述做复合键去重比单靠description去重稳。description单键的问题在于同一款酒两条评论可能措辞完全不同实质还是重复复合键至少保证这几项完全一致才判定重复。keepfirst保留先出现的行如果先出现的行恰好有缺失值可以改成keeplast再对比一次。5.4 三个文件合并后行数对不上现象reviews明明130k行merge完变120k或者反过来变150k。原因wineries或regions表存在重复键一对多合并把行撑大或者用inner join把没匹配上的评论丢了。解决print(df_wineries[review_id].is_unique) print(df_regions.duplicated(subset[province, country]).sum()) df_wineries df_wineries.drop_duplicates(review_id, keepfirst) df_regions df_regions.drop_duplicates(subset[province, country], keepfirst)逻辑说明merge前先验证键的唯一性两行print就能暴露问题。is_unique返回False说明wineries表的review_id不唯一先drop_duplicatesregions表按provincecountry查重复如果重复为0后面merge的many_to_one校验才可能通过。这里我总是把validate参数写上让pandas在合并时主动检查而不只是事后看shape。5.5 价格单位混用美元、欧元、人民币同一列现象price列出现29.99和29,99和€25混在一起pd.to_numeric转完一堆NaN。原因数据是多渠道合并来的原始导出端没有统一货币单位也没做符号清理。解决def clean_price(v): if not isinstance(v, str): return v v v.replace(US$, ).replace(€, ).replace(¥, ) v v.replace(,, .) return float(re.sub(r[^\d.], , v)) df[price] pd.to_numeric(df[price].apply(clean_price), errorscoerce) print(df[price].describe())逻辑说明先剥货币符号再把欧洲数字里的小数逗号替换成点最后用正则剥掉所有非数字非点字符。原则是宁可清洗多一步也不能让单位歧义进入模型。价格通常是评分预测里最强的结构化特征这列一旦脏掉模型整体提升空间小一大截。清洗后如果还有NaN说明数据里可能有“price range”之类的区间写法回头单拎出来处理。6. 把这份数据集沉淀成可复用的本地素材库贯穿三类场景的一条处理管线跑通模型不是终点把清洗、合并、特征工程固化下来才是。6.1 固定schema的标准化输出df_clean df_all[[ id, country, province, variety_norm, points, price, word_count, points_above_variety, ]] df_clean.to_parquet(data/wine_reviews_clean.parquet, indexFalse) df_clean.to_csv(data/wine_reviews_clean.csv, indexFalse)逻辑说明parquet格式留给pandas生态读写快体积小CSV留给跨工具交付比如下游是MySQL导入csv文件或者给Java、C#团队直接读取CSV都是通用格式。6.2 增量更新与版本管理新数据进来时按日期建目录存放原始文件清洗脚本统一读取所有原始文件再合并输出。模型训练前记录原始文件路径、清洗脚本版本、输出schema三个信息排错时能快速定位是哪一批数据带坏了模型。6.3 把模型结果变成一句话点评最后一个实用技巧用训练好的模型给新评论打预评分再结合word_count和price_bucket生成一句话摘要“这是一款中等价位的黑皮诺评论长度约80词预计评分88±2”。这一步不需要再优化模型而是把130k数据训练的结论变成业务上能直接用的工具。我自己的习惯是每次跑完数据都留下清洗后的快照而不是只存score文件。数据快照是后悔药模型随时能重训原始数据只有一份。希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →