简介:这是一份面向葡萄酒数据分析、文本挖掘与可视化练习的数据集,包含十三万余条专业品鉴记录,覆盖品种、产地、酒庄、价格及评论描述等关键字段,既适合入门者练习数据清洗与统计,也能支撑自然语言处理或推荐系统等进阶项目。资源共三个文件,两个CSV表格文件可用于批量分析与统计,一个JSON文件适合做结构化节点解析;压缩包体积约二十六点八四兆字节,整体层级清晰、便于按需取用。目前已有一百零二人浏览学习。借助该数据集,读者能获得完整的葡萄酒评论样本库,进行价格分布、评分关联、产地特征等探索性分析,也可提取评论文本开展情感分析或主题建模;两份CSV可对比不同数据规模下的处理性能,JSON文件则适合演示嵌套数据的转换实践,实用价值较高。
1. 葡萄酒评论数据集:130k+条记录能挖出什么,怎么挖
拿到这份葡萄酒评论数据集,第一反应是我手头终于有一份够大的、可以直接用的真实数据了。三个 CSV 文件,合计 13 万条以上记录,覆盖的维度不只是酒的名字和分数,还带着国家、地区、酒庄、价格、评论描述这些字段。对做数据分析、数据清洗练手或者想写一篇葡萄酒主题可视化文章的人来说,这份数据的价值在于它足够真实,而且自带"坑"——缺失值、重复记录、长尾分布全都有,练手和实战都合适。我的建议是别急着画图,先把数据结构摸清楚,后面每一步都能省时间。我会从读文件开始,一直走到统计和可视化,最后给一个能直接复用的分组画像脚本。
2. 读入与结构盘点:三份 CSV 如何拼成一张分析表
2.1 先看文件长什么样,再决定读法
CSV 数据集的通病是:你永远不知道第一行是不是表头、有没有引号转义、有没有空行。我拿到文件后的第一个动作不是pd.read_csv一把梭,而是先看文件前几行和大小。常见做法是用wc -l看行数,用head看前几行,确认分隔符和编码。
# 查看文件行数(含表头行) wc -l *.csv # 查看前3行,确认列名和数据格式 head -3 wine_reviews_1.csv这里wc -l统计的行数可能比实际记录数多一行,因为表头也算一行。head能直接看到列名,如果列名带空格或者引号,后面读入时就要指定参数。我遇到过一次某个 CSV 的列名里有括号和空格,直接read_csv后列名变成带空格的字符串,后续df.rename处理起来非常麻烦,所以这一步别省。
确认完文件结构后,我一般用pandas.read_csv读入,但会显式指定encoding、dtype和keep_default_na,因为不指定这几个参数,后面很容易栽在编码和数据类型的坑上。
import pandas as pd # 读取三个CSV文件,显式指定编码和列数据类型 df1 = pd.read_csv("wine_reviews_1.csv", encoding="utf-8", dtype={"price": "float64"}, keep_default_na=False) df2 = pd.read_csv("wine_reviews_2.csv", encoding="utf-8", dtype={"price": "float64"}, keep_default_na=False) df3 = pd.read_csv("wine_reviews_3.csv", encoding="utf-8", dtype={"price": "float64"}, keep_default_na=False) # 快速检查每个文件的行数和列名是否一致 print(df1.shape, df2.shape, df3.shape) print(df1.columns.tolist())dtype={"price": "float64"}是因为价格字段可能有小数,默认推断成 int 会丢精度。keep_default_na=False避免了空字符串被当成 NaN,这个在后面我会专门讲。三个文件结构一致是合并的前提,先打印列名对比一下,如果有差异,合并前要做列对齐,这一步能省掉后面大量的排错时间。
2.2 合并前必须做的三件事:列对齐、去重、类型统一
三份 CSV 合并不是简单的pd.concat,因为每一份文件对应的可能是不同批次的导出,列顺序、字段名、个别字段的格式都可能存在细微差异。我一般会先统一列名,再统一数据类型,最后做行级去重——按酒庄、酒名、年份和评论文本的组合去重。如果只按酒名去重,同一个酒庄的同名酒款会被误删。
# 统一列名,避免大小写和空格差异 standard_cols = { "Country": "country", "Region": "region", "Winery": "winery", "Wine": "wine_name", "Price": "price", "Points": "points", "Description": "description", } for df in [df1, df2, df3]: df.rename(columns=standard_cols, inplace=True) # 纵向合并 df = pd.concat([df1, df2, df3], ignore_index=True) # 按核心字段去重 df = df.drop_duplicates(subset=["winery", "wine_name", "points", "description"]) print(f"合并后总记录数: {len(df)}") print(f"去重后记录数: {len(df.drop_duplicates())}")rename是在原 DataFrame 上做修改,inplace=True这里用是安全的,因为这三个来源表后续不再单独使用。去重的subset我选择了四个字段而不是全部字段,原因是price和region可能有合法空值,如果把它们加入去重依据,两条完全相同的记录会因为一条缺价格而保留下来,造成误判。合并之后打印记录数,能直接看出三份文件里有多少重复。
2.3 字段语义说清楚:每列能干什么、不能干什么
这个数据集的核心字段我拆成三类:身份字段、数值字段、文本字段。身份字段包括country、region、winery、wine_name,用于定位一条评论描述的是哪款酒。数值字段包括price和points,其中points是评论者给的评分,一般落在 80 到 100 分之间,price是当时的市场参考价,单位是美元。文本字段是description,长度从几十个词到几百个词不等,适合做词频统计和简单情感分析。
这里有一个容易误用的点:points不是百分制成绩,它是类似于"评委评分"的绝对分数。不同酒庄、不同年份之间的points可以直接比较,但比较之前要看分布,因为大部分酒的分数集中在 84 到 92 分这个区间,极端低分和高分都很少。如果你要做"高性价比酒款推荐",正确的做法不是直接排序取 top N,而是在评分分布的长尾里找相对便宜的,我在后面会用代码演示。
3. 统计分析与可视化准备:用评分、价格和产地验证直觉
3.1 价格与评分的相关性:先算相关系数,再分组看
拿到合并后的数据,第一个值得做的分析是"价格是否真的和品质正相关"。直觉上贵酒评分高,但数据集里可能不是这么回事。反直觉结论常常藏在这种真实数据里:中间价位段的酒,评分方差最大;百元以内的酒反而容易出现 88 分以上的高评分。
# 剔除价格和评分为空的记录 df_clean = df[(df["price"].notna()) & (df["points"].notna())].copy() # 计算整体相关系数 corr = df_clean["price"].corr(df_clean["points"]) print(f"价格与评分的皮尔逊相关系数: {corr:.3f}") # 按价格区间分组,看各组评分均值和中位数 bins = [0, 20, 50, 100, 500, 5000] labels = ["<20", "20-50", "50-100", "100-500", ">500"] df_clean["price_group"] = pd.cut(df_clean["price"], bins=bins, labels=labels) group_stats = df_clean.groupby("price_group", observed=True)["points"].agg(["count", "mean", "median", "std"]) print(group_stats.round(2))pd.cut的作用是把连续的价格切成区间,bins和labels一一对应。observed=True在 pandas 2.x 里是必须加的参数,否则groupby会对空的区间也产生分组。输出结果里如果std(标准差)在 20-50 美元这个区间最大,说明这个价位的酒品质跨度大,挑酒需要看具体评论而不是只看价位。这时候相关系数可能只有 0.2 左右,属于弱相关——这就是"贵酒不一定好"的数据证据。
3.2 产地和评分的关系:用分组聚合找出高产区
产地分析是葡萄酒数据集里最常见的切片方式。国家字段直接可用,但地区字段要小心——同一个地区在不同国家可能重名,比如 "California" 在美国,"Rioja" 在西班牙。正确的做法是先按国家分组,再在国家内部按地区分组,而不是直接按地区分组。
# 按国家和地区两级分组,统计记录数、平均分和平均价格 region_stats = ( df_clean.groupby(["country", "region"], observed=True) .agg( review_count=("description", "count"), avg_points=("points", "mean"), avg_price=("price", "mean"), ) .reset_index() ) # 筛选评论数超过100条的地区,避免小样本噪音 region_stats = region_stats[region_stats["review_count"] >= 100] # 按平均分排序,输出Top 15 top_regions = region_stats.sort_values("avg_points", ascending=False).head(15) print(top_regions.to_string(index=False))agg里我用了三个不同的聚合方式:description计数代表评论条数,points求均值代表产区平均品质,price求均值代表产区价格水平。reset_index把分组键变回普通列,方便后续筛选。筛选review_count >= 100这一条非常关键,如果不做,一个只有两条评论的小产区可能因为分数高而排到前面,造成误导。
3.3 可视化前的数据塑形:把描述文本切成词频表
评论文本是最有信息量但也最需要预处理的部分。直接对整段评论做词频统计没有意义,因为冠词、介词会占据高频位置。我一般会做三步:全部转小写、去停用词、按词根合并。这里的"词根合并"不做完整形态还原,只做简单的单复数处理,够用就行。
import re from collections import Counter # 定义一个简易分词函数 def tokenize(text): # 只保留字母字符,转小写,按空格切分 words = re.findall(r"[a-z]+", text.lower()) # 停用词集合,按需扩展 stop_words = {"the", "a", "an", "and", "or", "but", "of", "for", "with", "wine"} return [w for w in words if w not in stop_words and len(w) > 3] # 对所有评论文本进行分词 all_words = Counter() for desc in df_clean["description"].fillna(""): all_words.update(tokenize(desc)) # 输出出现频率最高的20个词 print(all_words.most_common(20))re.findall(r"[a-z]+", text.lower())这一步把文本里所有连续的字母序列抽出来,标点符号和数字被直接丢弃。停用词集合里我放了一个 "wine" 是因为它出现频率极高但对区分葡萄酒风味没有帮助。Counter更新时是逐条评论叠加,这里的fillna("")是为了防止空值导致attributeerror。
4. 避坑清单:字段缺失、数据对齐与空值处理的实战记录
4.1 现象:合并后总行数比预期少了几千行
合并三个文件后,我用len(df)对比了三份文件行数之和,发现少了约 2000 行。第一反应是去重drop_duplicates删多了。检查后发现,问题是pd.concat默认按列名对齐——三份文件里有一份的列名是Wine Name带空格,另外两份是Wine,导致concat后产生了两个不同的列,合并后数据没有丢失,但drop_duplicates因为列名不一致而把所有行判定为唯一,直接删掉了重复行。
原因:三份 CSV 来自不同批次的导出,列名不统一是常态。解决:合并前先df.columns.str.strip().str.lower()做统一归一化,再rename到标准列名。从那以后我每拿到一批 CSV,第一件事就是打印列名做对齐,绝不跳过。
4.2 现象:价格列的均值高达 800 美元,明显失真
第一次做分组统计时,我发现某些地区的平均价格高得离谱,检查原始数据后发现有几十行价格是 999 或 888 这种整数,明显是缺价的占位符,不是真实价格。原因:数据导出时,缺失价格可能被填充成了特定标记值,而read_csv默认把它们读成正常数字。
# 将异常价格标记值替换为缺失值 import numpy as np df_clean["price"] = df_clean["price"].replace([888, 999, 0], np.nan) # 重新统计,查看缺失比例 missing_rate = df_clean["price"].isna().mean() print(f"价格缺失率: {missing_rate:.2%}")处理原则是:先看price的取值分布,确认哪些是标记值,再决定替换还是删除。replace方法支持传入列表,一次替换多个值。替换成np.nan后,后续分析里用dropna统一过滤。0也包含在替换列表里,因为实际在售的葡萄酒不可能标价 0 美元。
4.3 现象:评分列出现 99 分和 100 分,但对应评论内容有明显褒贬不一致
检查高分记录时发现,一些 99 分的酒在评论里用了 "poor finish" 或 "disappointing" 这样的负面词。原因:评分的列可能有错位——导出时某行数据结构发生偏移,评分字段读到了相邻列的位置。解决这类问题没有捷径,只能是抽样交叉验证:对每个点位字段做范围检查,points必须在 80 到 100 之间,price必须大于 0,description必须有超过 20 个字符。
# 范围合理性检查 invalid_points = df_clean[(df_clean["points"] < 80) | (df_clean["points"] > 100)] invalid_price = df_clean[df_clean["price"] <= 0] invalid_desc = df_clean[df_clean["description"].str.len() < 20] print(f"越界评分: {len(invalid_points)} 条") print(f"非法价格: {len(invalid_price)} 条") print(f"异常短评论: {len(invalid_desc)} 条")这一步的价值不只是清洗,更重要的是确认数据整体可信度。越界评分和非法价格可以用drop或replace处理,但异常短评论要慎重——有可能是正常评论,只是特别简短。实操里我建议把筛选条件放宽到 10 个字符以下再做人工确认,避免误删。
4.4 现象:同一款酒出现两条完全相同的评论,但去重后仍然存在
重复记录的去重我在前面已经做了,但做完全字段去重后仍有少量重复。排查后发现是description字段里包含不可见的换行符差异,比如一条是\n结尾,另外一条是\r\n结尾。原因:CSV 导出时如果经过不同的操作系统,换行符会被转成不同形式。解决:去重前先做文本规范化,把\r\n统一替换为\n并做 strip。
# 规范化描述文本,去除换行符差异 df_clean["description_norm"] = ( df_clean["description"] .astype(str) .str.replace("\r\n", "\n", regex=False) .str.strip() ) # 基于规范化后的描述去重 df_dedup = df_clean.drop_duplicates(subset=["winery", "wine_name", "description_norm"]) print(f"规范化去重后: {len(df_dedup)} 条")astype(str)是防止个别行是 NaN 导致后续str.replace报错。我用正则regex=False做了字面量替换,因为这里不需要正则表达式,直接替换更安全。去重后description_norm可以保留,后续做词频分析时直接用它而不是原始字段。
5. 进阶技巧:用分组聚合快速生成酒款画像表
5.1 画像表是什么,为什么有用
酒款画像表是我自己习惯的一个叫法,本质上是一张把「风格关键词、价位段、评分、产地」压缩到一行的汇总表。传统做法是写完分析后逐个找典型酒款,费时间。我一般会先做一张画像表,把每个葡萄酒品种对应的最高频风味词、平均评分、平均价格算好,再去原文里挑具体酒款就非常快。这张表对写推荐文案或者做快速筛选都很有用。
5.2 生成画像表的具体步骤
# 提取品种名称:从酒名中识别常见品种关键词 varietal_keywords = { "cabernet sauvignon": ["cabernet", "cab"], "chardonnay": ["chardonnay"], "pinot noir": ["pinot"], "merlot": ["merlot"], "syrah": ["syrah", "shiraz"], "sauvignon blanc": ["sauvignon"], } def detect_varietal(wine_name): if not isinstance(wine_name, str): return "unknown" wine_lower = wine_name.lower() for varietal, keywords in varietal_keywords.items(): for kw in keywords: if kw in wine_lower: return varietal return "unknown" df_dedup["varietal"] = df_dedup["wine_name"].map(detect_varietal) # 按品种分组,聚合价格、评分和评论关键词 def get_top_keywords(series, top_n=5): counter = Counter() for text in series.fillna(""): counter.update(tokenize(str(text))) return ", ".join([word for word, _ in counter.most_common(top_n)]) varietal_profile = ( df_dedup.groupby("varietal", observed=True) .agg( count=("varietal", "size"), avg_points=("points", "mean"), avg_price=("price", "mean"), top_keywords=("description_norm", get_top_keywords), ) .reset_index() ) # 筛选出现次数超过50次的品种,避免小样本 varietal_profile = varietal_profile[varietal_profile["count"] > 50] print(varietal_profile.round(2).to_string(index=False))detect_varietal函数用最简单的子串匹配识别品种,没有引入复杂的命名实体识别,对这份数据来说够用了。map方法把函数应用到wine_name的每个值上。get_top_keywords是一个聚合函数,传入的是 Series,返回的是字符串,所以可以传给agg。这种自定义聚合方式和count、mean混用是没有问题的,但要注意get_top_keywords必须接收一个 Series 并返回一个标量,否则agg会报错。
5.3 画像表怎么用:筛选高性价比酒款
有了画像表,筛选逻辑就清晰了:先看每个品种的平均评分和平均价格,找出「评分高于该品种均值、价格低于该品种均值」的酒款。这样做的依据是——同一品种内部的评分方差通常小于不同品种之间的方差。直接跨品种比分数没有意义,但品种内部比就有参考价值。
# 计算每个品种的评分均值,并与单瓶酒对比 merged = df_dedup.merge( varietal_profile[["varietal", "avg_points"]], on="varietal", how="left", ) # 筛选高于品种均值、价格低于100美元的酒款 high_value = merged[ (merged["points"] > merged["avg_points"]) & (merged["price"].notna()) & (merged["price"] < 100) ] # 按评分降序输出最值得关注的10款 result = ( high_value.sort_values("points", ascending=False) .head(10)[["varietal", "wine_name", "points", "price", "region"]] ) print(result.to_string(index=False))merge是在varietal键上做左连接,把品种均值拼到每一行。筛选条件同时要求points > avg_points和price < 100,这个组合在真实数据里能筛出大概几百条记录,说明数据集里确实存在被低估的酒款——评分高于同类平均水平,但价格不到 100 美元。逻辑上看,price为空的行被notna()排除了,避免低价筛选把缺价记录误判为便宜货。
从那以后我把这套流程固定为三个步骤:先read_csv时显式声明类型,再concat前统一列名,最后任何筛选动作前先看一眼isna()统计。每次换一份新数据集,我都强制走一遍这个路径。这份葡萄酒评论数据集值得折腾的地方不在于它大,而在于它真实地暴露了 CSV 数据在实战中会遇到的各种脏情况,希望帮到你。
本文还有配套的精品资源,点击获取