- 示例工程
- 教程
【免费下载链接】materials
Bonus materials, exercises, and example projects for our Python tutorials
本文基于 materials 仓库 pandas-intro 目录下的配套教学 notebook pandas_intro.ipynb 展开,以 NBA 历史比赛数据(来自 FiveThirtyEight 的nbaallelo.csv)为贯穿案例,系统讲解用 Pandas 探索数据集的核心流程。读完本文,你将掌握read_csv读取数据、.info()/.describe()快速体检、Series/DataFrame两大数据结构、.loc/.iloc索引访问、布尔查询、聚合分组、列增删改、缺失值清洗、concat/merge多表合并,以及基于 matplotlib 的快速可视化,形成一条可复制的"读数据 → 认识数据 → 清洗数据 → 分析数据 → 呈现数据"完整链路。
环境准备与数据集获取
安装依赖
本教程的完整代码均可通过 Python 3 运行,核心依赖只有三个:pandas(数据分析)、requests(下载数据集)、matplotlib(绘图)。在 notebook 中提供了两种安装方式(见 pandas_intro.ipynb 的 Environment Setup 章节):
$ python3 -m pip install requests pandas matplotlib使用 conda 环境时:
$ conda install requests pandas matplotlib下载 NBA 数据集
教程使用五八同城(FiveThirtyEight)公开的 NBA ELO 历史数据集作为探索对象。notebook 通过requests直接下载 CSV 并落盘为nba_all_elo.csv:
import requests download_url = "https://raw.githubusercontent.com/fivethirtyeight/data/master/nba-elo/nbaallelo.csv" target_csv_path = "nba_all_elo.csv" response = requests.get(download_url) response.raise_for_status() with open(target_csv_path, "wb") as f: f.write(response.content) print("Download ready.")该数据集涵盖了从 1946 年至今的 NBA 比赛记录,包含比分、球队、ELO 评分、比赛地点等 23 个原始字段,规模庞大且贴近真实业务数据,非常适合用来练习数据探索。
初见 DataFrame:读取与基本概览
读取 CSV 并确认对象类型
import pandas as pd nba = pd.read_csv("nba_all_elo.csv") type(nba) # Expected: # <class 'pandas.core.frame.DataFrame'>pd.read_csv()返回的核心对象是DataFrame——Pandas 中最常用的二维表格数据结构。紧接着可以用三个"灵魂方法"快速把握数据规模:
len(nba) # Expected: # 126314 nba.shape # Expected: # (126314, 23)len()返回行数(126314 行),.shape返回(行数, 列数)元组(23 列)。仅用两行代码就能确认这是一个超过 12 万行的中型数据集。
用.head()和.tail()预览数据
nba.head() nba.tail().head()默认展示前 5 行,.tail()展示后 5 行,是快速浏览数据长相最直观的方式。
调整显示选项
数据集中有 23 列,默认显示会截断。notebook 中通过pd.set_option()调整全局显示参数:
pd.set_option("display.max.columns", None) # 显示所有列 pd.set_option("display.precision", 2) # 数值精度保留两位小数display.max.columns设为None表示不限制列显示数量;display.precision控制浮点数的显示精度(不影响底层存储值)。这两个选项对后续所有 DataFrame 的输出都生效。
练习:你能打印出这个 DataFrame 的最后 3 行吗?(提示:
nba.tail(3))
深入了解数据:.info()与.describe()
用.info()查看数据类型与缺失情况
nba.info().info()输出每个列的类型(dtype)与非空值(non-null)数量,是判断数据"有没有缺、缺多少"的第一手资料。从 notebook 后续的renamed_df.info()输出可以看到该类方法呈现的信息形态:
<class 'pandas.core.frame.DataFrame'> RangeIndex: 126314 entries, 0 to 126313 Data columns (total 25 columns): gameorder 126314 non-null int64 ... notes 5424 non-null object date_played 126314 non-null datetime64[ns] difference 126314 non-null int64 dtypes: datetime64ns, float64(6), int64(8), object(10) memory usage: 24.1+ MB从这份输出可以读出三个关键信息:gameorder是int64整数、notes只有 5424 个非空值(意味着存在大量缺失)、date_played是datetime64[ns]日期类型,同时还能看到内存占用估算。
用.describe()查看统计摘要
nba.describe().describe()默认对数值列输出count、mean、std、min、四分位数和max等描述统计。若要查看对象(字符串)列,需要传入参数:
nba.describe(include=object)此时输出的将变为count、unique、top(出现最多的值)、freq(该值出现次数)——这对于快速了解分类字段的分布非常有帮助,例如查看notes列最常见的值是什么。
迷你数据探索:计数、筛选与日期聚合
notebook 用一个"迷你探索"串起了多个核心 API。首先是.value_counts()统计分类取值频次:
nba["team_id"].value_counts() # Expected: # BOS 5997 # NYK 5769 # LAL 5078 ... # SDS 11 nba["fran_id"].value_counts() # Expected: # Lakers 6024 # Celtics 5997 # Knicks 5769 ... # Falcons 60 # Name: fran_id, dtype: int64接着组合.loc布尔筛选与.value_counts(),看看 Lakers 名下有哪些具体的team_id:
nba.loc[nba["fran_id"] == "Lakers", "team_id"].value_counts() # Expected: # LAL 5078 # MNL 946 # Name: team_id, dtype: int64然后是日期处理。原始数据中日期是字符串列date_game,先用pd.to_datetime()转换为真正的日期类型:
nba["date_played"] = pd.to_datetime(nba["date_game"])转换后即可对 MNL(明尼阿波利斯湖人)这支球队的比赛日期求最小值和最大值:
nba.loc[nba["team_id"] == "MNL", "date_played"].min() # Expected: # Timestamp('1948-11-04 00:00:00') nba.loc[nba["team_id"] == "MNL", "date_played"].max() # Expected: # Timestamp('1960-03-26 00:00:00')甚至可以用.agg()一次性传入多个聚合函数:
nba.loc[nba["team_id"] == "MNL", "date_played"].agg(("min", "max")) # Expected: # min 1948-11-04 # max 1960-03-26 # Name: date_played, dtype: datetime64[ns]练习:找出波士顿凯尔特人(Boston Celtics)在本数据集中所有比赛的总得分。
Pandas 核心数据结构:Series 与 DataFrame
Series:Pandas 的基本构件
Series是一维带标签数组。从普通列表创建时自动生成RangeIndex:
revenues = pd.Series([5555, 7000, 1980]) revenues # Expected: # 0 5555 # 1 7000 # 2 1980 # dtype: int64其内部数据(.values)本质上是 NumPy 数组,而索引(.index)独立存在:
revenues.values # Expected: # array([5555, 7000, 1980]) revenues.index # Expected: # RangeIndex(start=0, stop=3, step=1) type(revenues.values) # Expected: # <class 'numpy.ndarray'>也可以显式指定标签索引:
city_revenues = pd.Series( [4200, 8000, 6500], index=["Amsterdam", "Toronto", "Tokyo"] ) city_revenues # Expected: # Amsterdam 4200 # Toronto 8000 # Tokyo 6500 # dtype: int64Series 与 Python 内置结构的对比
用字典直接创建 Series,标签即字典键,此时它表现得像增强版字典:
city_employee_count = pd.Series({"Amsterdam": 5, "Tokyo": 8}) city_employee_count # Expected: # Amsterdam 5 # Tokyo 8 # dtype: int64 city_employee_count.keys() # Expected: # Index(['Amsterdam', 'Tokyo'], dtype='object') "Tokyo" in city_employee_count # Expected: # True "New York" in city_employee_count # Expected: # False注意:in检查的是索引标签而非值,这与 Python 字典的成员判断语义一致。
DataFrame:Pandas 最流行的数据结构
DataFrame本质上是共享索引的多个 Series 的集合。从字典构造时,字典值可以是 Series,缺失位置自动填充NaN:
city_data = pd.DataFrame( {"revenue": city_revenues, "employee_count": city_employee_count} ) city_data # Expected: # revenue employee_count # Amsterdam 4200 5.0 # Tokyo 6500 8.0 # Toronto 8000 NaN索引、数值、轴都可以分别取出:
city_data.index # Expected: # Index(['Amsterdam', 'Tokyo', 'Toronto'], dtype='object') city_data.values # Expected: # array([[4.2e+03, 5.0e+00], # [6.5e+03, 8.0e+00], # [8.0e+03, nan]]) city_data.axes # Expected: # [Index(['Amsterdam', 'Tokyo', 'Toronto'], dtype='object'), # Index(['revenue', 'employee_count'], dtype='object')].axes[0]是行(索引)轴、.axes[1]是列轴。与 Series 不同,DataFrame的in成员判断针对列名而非行标签:
city_data.keys() # Expected: # Index(['revenue', 'employee_count'], dtype='object') "Amsterdam" in city_data # Expected: # False "revenue" in city_data # Expected: # True练习:显示
nba数据集的 index 和 axes,并检查它有没有名为 "points" 的列——还是叫 "pts"?
元素访问:Python list 索引运算符与 Series 的异同
[]索引运算符的便利与局限
对带标签的 Series,[]既支持标签又支持位置:
city_revenues["Toronto"] # Expected: # 8000 city_revenues[1] # Expected: # 8000 city_revenues[-1] # Expected: # 6500切片的语义同样"双通道":
city_revenues[1:] # Expected: # Toronto 8000 # Tokyo 6500 # dtype: int64 city_revenues["Toronto":] # Expected: # Toronto 8000 # Tokyo 6500 # dtype: int64但混用两种语义很容易造成歧义,这正是.loc和.iloc存在的意义。
用.loc与.iloc精确访问
看一个经典陷阱:当 Series 的显式索引与位置编号不一致时,[]的行为会变得含糊。notebook 构造了一个索引为[1, 2, 3, 5, 8]的颜色 Series:
colors = pd.Series( ["red", "purple", "blue", "green", "yellow"], index=[1, 2, 3, 5, 8] ) colors # Expected: # 1 red # 2 purple # 3 blue # 5 green # 8 yellow # dtype: object此时必须区分"按标签取"与"按位置取":
colors.loc[1] # 按标签 1 → 取第 0 个元素 # Expected: # 'red' colors.iloc[1] # 按位置 1 → 取第 1 个元素 # Expected: # 'purple'切片方面,.loc的切片是闭区间(包含两端标签),.iloc的切片是半开区间(不含末尾位置):
# Return the elements with the implicit index: 1, 2 colors.iloc[1:3] # Expected: # 2 purple # 3 blue # dtype: object # Return the elements with the explicit index between 3 and 8 colors.loc[3:8] # Expected: # 3 blue # 5 green # 8 yellow # dtype: object colors.iloc[-2] # 负数按位置从尾部数 # Expected: # 'green'访问 DataFrame 的元素
访问列
[]传入列名返回一个 Series:
city_data["revenue"] # Expected: # Amsterdam 4200 # Tokyo 6500 # Toronto 8000 # Name: revenue, dtype: int64 type(city_data["revenue"]) # Expected: # <class 'pandas.core.series.Series'>列名若符合 Python 标识符规则,还可以用属性访问(city_data.revenue),二者结果一致,但属性访问无法处理带空格或特殊字符的列名。
需要注意toys.shape与toys["shape"]的区别——前者是 DataFrame 的维度属性,后者是名为shape的列:
toys = pd.DataFrame( [ {"name": "ball", "shape": "sphere"}, {"name": "Rubik's cube", "shape": "cube"}, ] ) toys["shape"] # Expected: # 0 sphere # 1 cube # Name: shape, dtype: object toys.shape # Expected: # (2, 2)访问行:.loc与.iloc
city_data.loc["Amsterdam"] # Expected: # revenue 4200.0 # employee_count 5.0 # Name: Amsterdam, dtype: float64 city_data.loc["Tokyo":"Toronto"] # Expected: # revenue employee_count # Tokyo 6500 8.0 # Toronto 8000 NaN city_data.iloc[1] # Expected: # revenue 6500.0 # employee_count 8.0 # Name: Tokyo, dtype: float64练习:显示
nba数据集的倒数第二行。
同时访问行与列的子集
.loc的第一个位置是行标签切片,第二个位置是列名切片:
city_data.loc["Amsterdam":"Tokyo", "revenue"] # Expected: # Amsterdam 4200 # Tokyo 6500 # Name: revenue, dtype: int64练习:查看标签 5555 到 5559 之间的比赛记录,只关心球队名称和双方比分。
用布尔条件查询数据
Pandas 的查询本质是布尔掩码:先构造与 DataFrame 等长的布尔 Series,再放入[]中过滤出True的行。
按数值条件过滤:
current_decade = nba[nba["year_id"] > 2010] current_decade.shape # Expected: # (12658, 24)按非空条件过滤:
games_with_notes = nba[nba["notes"].notnull()] games_with_notes.shape # Expected: # (5424, 24)用字符串方法过滤(.str访问器):
ers = nba[nba["fran_id"].str.endswith("ers")] ers.shape # Expected: # (27797, 24)多个条件组合时,必须用&(与)和|(或)而非 Python 的and/or,并且每个条件都要加括号:
nba[ (nba["_iscopy"] == 0) & (nba["pts"] > 100) & (nba["opp_pts"] > 100) & (nba["team_id"] == "BLB") ]练习:1992 年春天,洛杉矶的两支球队都曾在另一块场地打主场比赛。找出这两场比赛(两支球队的 ID 都以 "LA" 开头)。
聚合与分组
Series 聚合
city_revenues.sum() # Expected: # 18700 city_revenues.max() # Expected: # 8000DataFrame 聚合
先从 DataFrame 取出pts列(得到一个 Series),再求总和:
points = nba["pts"] type(points) # Expected: # <class 'pandas.core.series.Series'> points.sum() # Expected: # 12976235分组聚合:groupby
.groupby()是 Pandas 分析能力的核心。按球队全名分组求和:
nba.groupby("fran_id", sort=False)["pts"].sum() # Expected: # fran_id # Huskies 3995 # Knicks 582497 # Stags 20398 # Falcons 3797 # Capitols 22387sort=False表示保留数据原始分组顺序而非按分组键排序。也可以按多个列分组——下面统计马刺 2011 年之后每个赛季常规赛/季后赛(game_result)的胜场数:
nba[(nba["fran_id"] == "Spurs") & (nba["year_id"] > 2010)].groupby( ["year_id", "game_result"] )["game_id"].count() # Expected: # year_id game_result # 2011 L 25 # W 63 # 2012 L 20 # W 60 # 2013 L 30 # W 73 # 2014 L 27 # W 78 # 2015 L 31 # W 58 # Name: game_id, dtype: int64关于groupby的更深入用法(多列分组、agg多函数组合等),可进一步参考仓库中的 pandas-groupby/README.md 及其配套数据文件。
练习:看看金州勇士(Golden State Warriors)2014-15 赛季(
year_id为 2015)在常规赛和季后赛各取得了多少胜场与负场。
操作列:新增、重命名、删除与类型转换
对原始数据做任何修改前,先复制一份,避免污染原始数据:
df = nba.copy() df.shape # Expected: # (126314, 24)新增列
新增列本质上就是"按行对齐"的向量运算,直接赋值即可:
df["difference"] = df.pts - df.opp_pts df.shape # Expected: # (126314, 25) df["difference"].max() # Expected: # 68重命名列
.rename()通过columns参数传入新旧列名映射,返回新对象(不修改原对象):
renamed_df = df.rename( columns={"game_result": "result", "game_location": "location"} )renamed_df.info()中即可看到game_result已变成result、game_location已变成location。
删除列
elo_columns = ["elo_i", "elo_n", "opp_elo_i", "opp_elo_n"] df.drop(elo_columns, inplace=True, axis=1) df.shape # Expected: # (126314, 21)axis=1表示按列方向删除,inplace=True表示原地修改当前对象。从df.shape由(126314, 25)变为(126314, 21)可以看到一次性删掉了 4 列 ELO 评分字段。
更改数据类型
先用.info()检查各列类型,再逐列转换。字符串日期转日期类型:
df["date_game"] = pd.to_datetime(df["date_game"])字符串枚举列转Categorical可以大幅节省内存并提升分组性能。转换前先看取值分布:
df["game_location"].nunique() # Expected: # 3 df["game_location"].value_counts() # Expected: # A 63138 # H 63138 # N 38 # Name: game_location, dtype: int64只有 3 个取值(A 客场、H 主场、N 中立场地),非常适合转为分类类型:
df["game_location"] = pd.Categorical(df["game_location"]) df["game_location"].dtype # Expected: # CategoricalDtype(categories=['A', 'H', 'N'], ordered=False)练习:在
nba数据集中找出另一个"数据类型过于宽泛"的列,并思考它适合转成什么类型。
清洗数据:缺失值、无效值与矛盾值
缺失值处理
再次通过.info()定位缺失:notes列只有 5424 个非空值,其余 12 万行都是NaN。
按需选择处理策略:
- 删除含缺失的行(
.dropna()默认按行删):
rows_without_missing_data = nba.dropna() rows_without_missing_data.shape # Expected: # (5424, 24)12 万行数据因为notes大量缺失只剩下 5424 行——可见"删行"对稀疏字段是代价极高的操作。
- 删除含缺失的列(
axis=1):
data_without_missing_columns = nba.dropna(axis=1) data_without_missing_columns.shape # Expected: # (126314, 23)- 填充缺失值(
.fillna()):
data_with_default_notes = nba.copy() data_with_default_notes["notes"].fillna(value="no notes at all", inplace=True) data_with_default_notes["notes"].describe() # Expected: # count 126314 # unique 232 # top no notes at all # freq 120890 # Name: notes, dtype: object填充后notes列count变为 126314(无缺失),且describe(include=object)显示最频繁值变成了默认文案no notes at all。
无效值检查
pts为 0 的比赛记录属于可疑数据,直接筛出来人工核验:
nba[nba["pts"] == 0]列间矛盾值检查
还可以用布尔查询验证不同列之间的逻辑一致性——得分高于对手的比赛结果必然为 "W",低于对手必然为 "L":
nba[(nba["pts"] > nba["opp_pts"]) & (nba["game_result"] != "W")].empty # Expected: # True nba[(nba["pts"] < nba["opp_pts"]) & (nba["game_result"] != "L")].empty # Expected: # True.empty返回True表示筛选结果为空,即数据集中不存在此类矛盾记录——这是数据质量验证的经典写法。
处理多个数据集:concat 与 merge
基于索引纵向拼接:concat()
准备第二份城市数据并按行拼接(axis=0为默认):
further_city_data = pd.DataFrame( {"revenue": [7000, 3400], "employee_count": [2, 2]}, index=["New York", "Barcelona"], ) all_city_data = pd.concat([city_data, further_city_data], sort=False) all_city_data # Expected: # Amsterdam 4200 5.0 # Tokyo 6500 8.0 # Toronto 8000 NaN # New York 7000 2.0 # Barcelona 3400 2.0按列拼接(axis=1)时,行索引自动对齐,缺失位置填充NaN:
city_countries = pd.DataFrame( { "country": ["Holland", "Japan", "Holland", "Canada", "Spain"], "capital": [1, 1, 0, 0, 0], }, index=["Amsterdam", "Tokyo", "Rotterdam", "Toronto", "Barcelona"], ) cities = pd.concat([all_city_data, city_countries], axis=1, sort=False) cities # Expected: # revenue employee_count country capital # Amsterdam 4200.0 5.0 Holland 1.0 # Tokyo 6500.0 8.0 Japan 1.0 # Toronto 8000.0 NaN Canada 0.0 # New York 7000.0 2.0 NaN NaN # Barcelona 3400.0 2.0 Spain 0.0 # Rotterdam NaN NaN Holland 0.0如果只想保留两侧索引都存在的行,可以指定join="inner":
pd.concat([all_city_data, city_countries], axis=1, join="inner") # Expected: # revenue employee_count country capital # Amsterdam 4200 5.0 Holland 1 # Tokyo 6500 8.0 Japan 1 # Toronto 8000 NaN Canada 0 # Barcelona 3400 2.0 Spain 0关于concat/merge/join的更多组合方式,可参考仓库中的 introduction-combining-data-pandas-merge-join-and-concat/pandas_merge.ipynb(含配套气候数据 CSV)。
基于任意列合并:merge()
merge()允许不按索引、而是按任意列进行连接。下面将cities的国家名(country列)与countries的索引对齐:
countries = pd.DataFrame( { "population_millions": [17, 127, 37], "continent": ["Europe", "Asia", "North America"], }, index=["Holland", "Japan", "Canada"], ) pd.merge(cities, countries, left_on="country", right_index=True)left_on="country"指定左表用country列,right_index=True指定右表用索引,默认how="inner"(只保留能匹配上的行)。如果希望保留左表所有行、无法匹配的填NaN,则用how="left":
pd.merge(cities, countries, left_on="country", right_index=True, how="left")可视化你的 DataFrame
在 notebook 中先启用内嵌绘图模式(Jupyter 环境):
%matplotlib inline之后任何返回 Series/DataFrame 的聚合结果都可以直接调用.plot()出图。按年份绘制尼克斯(Knicks)每年总得分的趋势线:
nba[nba["fran_id"] == "Knicks"].groupby("year_id")["pts"].sum().plot()绘制球队出现频次的横向柱状图(kind="bar"):
nba["fran_id"].value_counts().head(10).plot(kind="bar")Pandas 的.plot()默认基于 matplotlib 实现,kind参数支持line、bar、barh、hist、box、kde、pie、scatter等多种图形类型,是"聚合结果 → 快速可视化"的最短路径。
练习:2013 年迈阿密热火(Miami Heat)夺冠。创建一个饼图(pie plot),展示该赛季他们的胜负场次分布。
总结:一条完整的数据探索链路
从 pandas_intro.ipynb 的实践可以看出,一次规范的数据探索之旅可以归纳为六个阶段,每个阶段对应本文讲解的核心 API:
- 读取与规模感知:
pd.read_csv()→len()、.shape、.head()、.tail(); - 结构与统计体检:
.info()(类型与缺失)、.describe()(数值/对象统计摘要); - 数据选择与筛选:
[]、.loc、.iloc、布尔掩码组合查询; - 聚合与分组:
.sum()、.max()、.agg()、.groupby(); - 列操作与清洗:新增/重命名/删除/类型转换、
dropna/fillna、矛盾值校验; - 多表合并与可视化:
concat()、merge()、.plot()。
这套方法论既适用于本仓库中 NBA 数据集这类单表场景,也能平滑迁移到多源数据的日常分析工作中。如果你希望深入相邻主题,仓库中还提供了相关配套资源:pandas-groupby/README.md 专门深入讲解groupby的高级用法,pandas-iterate-over-rows/README.md 则讨论了"为什么不应逐行遍历 DataFrame"及其替代方案。掌握了本文的数据探索主干后,配合这些主题,就能建立起一个完整的 Pandas 数据分析知识体系。
- 示例工程
- 教程
【免费下载链接】materials
Bonus materials, exercises, and example projects for our Python tutorials
相关推荐
Gradio 连接数据库实战:从 pandas/SQLAlchemy 查询到可部署的数据可视化仪表盘
Gradio 连接数据库实战:从 pandas/SQLAlchemy 查询到可部署的数据可视化仪表盘 导读 数据要展示在 Web 界面上,往往并不以 CSV 或
前端后端AI 应用Pandas-Datareader实战指南:从数据获取到分析可视化的全流程方案
Pandas Datareader实战指南:从数据获取到分析可视化的全流程方案 在金融量化分析领域,高效获取和处理市场数据是构建投资策略的基础。然而,金融数据接
数据分析金融科技SeaTunnel Neo4j Source Connector 完全指南:从 Cypher 查询到 SeaTunnel Row 的数据读取实战
SeaTunnel Neo4j Source Connector 完全指南:从 Cypher 查询到 SeaTunnel Row 的数据读取实战 本文以 Apa
数据工程大数据批处理流处理
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考