Python数据分析实战:用Pandas处理2012-2019年运动员收入排行榜(附完整代码)

最近几年,数据驱动的决策方式几乎渗透到了所有行业,体育产业也不例外。无论是俱乐部经理评估球员价值,还是品牌方寻找代言人,一份详实、清晰的运动员收入数据都能提供极具价值的洞察。对于数据分析师或开发者而言,这类结构化的年度排行榜数据,是练习数据处理技能的绝佳“沙盒”。它不像金融数据那样敏感,又比虚构的练习数据集更贴近真实业务场景,包含了数据清洗、类型转换、分组聚合、条件筛选等一系列经典操作。

今天,我们就以一份2012年至2019年全球运动员收入排行榜的CSV数据为例,手把手带你用Python的Pandas库,完成一次从原始数据到深度分析的全流程实战。我会分享一些我在处理类似数据时总结的高效技巧和容易踩的“坑”,目标是让你不仅能复现操作,更能理解每一步背后的逻辑,最终能独立设计并完成属于自己的数据分析项目。无论你是刚接触Pandas的新手,还是想巩固实战技能的中级开发者,这篇文章都能提供直接的参考价值。

1. 环境准备与数据初探

在开始任何数据分析项目之前,搭建一个清晰、可复现的工作环境是第一步。我个人的习惯是使用Anaconda来管理Python环境和包,它能有效避免不同项目间的依赖冲突。对于这个项目,我们主要依赖pandasnumpymatplotlibseaborn则用于后续可能的数据可视化(本文重点在数据处理,可视化会简要提及)。

# 使用pip安装核心库
pip install pandas numpy matplotlib seaborn

数据文件通常不会“天生完美”。我们拿到的2012-19sport.csv文件,其结构可能隐藏着一些问题。用Pandas的read_csv函数读取时,有几个参数需要特别注意:

import pandas as pd

# 尝试读取数据,注意可能的陷阱
try:
    df = pd.read_csv('2012-19sport.csv', encoding='utf-8')
except UnicodeDecodeError:
    # 如果utf-8失败,尝试其他常见编码
    df = pd.read_csv('2012-19sport.csv', encoding='gbk', errors='ignore')

# 快速查看数据前5行和整体信息
print("数据形状(行数,列数):", df.shape)
print("\n前5行数据预览:")
print(df.head())
print("\n数据基本信息:")
print(df.info())
print("\n各列统计摘要:")
print(df.describe(include='all'))

运行上述代码,你可能会立刻发现几个典型问题:

  1. 列名可能不规范:原始数据的第一行可能是列标题,但格式混乱(如带有#号)。
  2. 数据类型错误:收入列(如Pay)很可能被识别为object(字符串)类型,因为包含了$M等货币符号。
  3. 缺失值:某些运动员的SalaryEndorsement信息可能为空。

提示:df.info()是了解数据内存占用和各列类型的利器,而df.describe(include='all')则能一次性查看数值型和分类型变量的统计概况。

初次查看后,我们通常需要手动指定或清洗列名。假设原始数据没有表头,或者表头在第一行但格式不佳,我们可以这样处理:

# 假设第一行是数据,我们需要手动设置列名
# 根据数据描述,列顺序为:Rank, Name, Pay, Salary/Winnings, Endorsements, Sport, Year
column_names = ['Rank', 'Name', 'Pay', 'Salary', 'Endorsements', 'Sport', 'Year']
df = pd.read_csv('2012-19sport.csv', names=column_names, header=None, encoding='utf-8')

# 或者,如果第一行是带#号的标题,可以先读取再处理
df_raw = pd.read_csv('2012-19sport.csv', encoding='utf-8')
# 检查第一列名,如果是以#开头,则重命名
if df_raw.columns[0].startswith('#'):
    df_raw.columns = column_names
    df = df_raw

2. 深度数据清洗与预处理

数据清洗往往占据数据分析80%的时间,这一步的质量直接决定后续分析的可靠性。针对我们的运动员收入数据,清洗工作主要集中在几个方面:无效字符去除、数据类型转换、异常值处理以及缺失值填补

2.1 处理货币字符串列

PaySalaryEndorsements这三列是分析的核心,但它们目前是像"$127 M"这样的字符串。我们需要提取其中的数字部分,并转换为浮点数(以百万美元为单位)。这里我推荐使用向量化的字符串操作,比用for循环快得多。

# 定义一个函数来清洗货币列
def clean_currency_column(series):
    """
    将形如'$127 M'的字符串转换为浮点数127.0
    """
    # 移除美元符号$和字母M,以及可能存在的空格
    return series.str.replace('$', '', regex=False)\
                  .str.replace(' M', '', regex=False)\
                  .astype(float)

# 应用清洗函数
df['Pay_clean'] = clean_currency_column(df['Pay'])
df['Salary_clean'] = clean_currency_column(df['Salary'])
df['Endorsements_clean'] = clean_currency_column(df['Endorsements'])

# 验证清洗结果
print(df[['Pay', 'Pay_clean']].head())
print(f"总收入列数据类型: {df['Pay_clean'].dtype}")

2.2 验证数据一致性

原始描述中提到 Pay = Salary + Endorsements。这是一个绝佳的数据质量检查点。我们可以计算清洗后的数值列是否满足这个等式,从而发现数据录入错误。

# 计算清洗后工资与代言费之和
df['Calculated_Pay'] = df['Salary_clean'] + df['Endorsements_clean']

# 比较计算出的Pay与原始的Pay_clean,允许微小浮点数误差
tolerance = 0.01  # 1美分(0.01百万美元)的容差
inconsistent = df[abs(df['Pay_clean'] - df['Calculated_Pay']) > tolerance]

if inconsistent.empty:
    print("数据一致性检查通过:所有记录的Pay等于Salary与Endorsements之和。")
else:
    print(f"发现 {len(inconsistent)} 条不一致记录:")
    print(inconsistent[['Name', 'Year', 'Pay_clean', 'Salary_clean', 'Endorsements_clean', 'Calculated_Pay']].head())
    # 处理策略:可以选择用计算值覆盖,或标记为异常进一步调查
    # df.loc[inconsistent.index, 'Pay_clean'] = inconsistent['Calculated_Pay']

2.3 处理缺失值与异常值

对于缺失值,我们需要根据业务逻辑决定处理方式。如果某个运动员的Salary缺失,但PayEndorsements存在,我们可以反推Salary = Pay - Endorsements。如果整条记录关键信息缺失过多,则考虑删除。

# 检查各列缺失值数量
missing_summary = df.isnull().sum()
print("缺失值统计:")
print(missing_summary[missing_summary > 0])

# 处理Rank列:如果为NaN,可能是数据错位,需谨慎处理或删除整行
df_clean = df.dropna(subset=['Rank', 'Name', 'Year']).copy()

# 处理数值列缺失:对于少数缺失,可以用中位数或0填充,具体看情况
# 例如,假设Endorsements缺失意味着没有代言收入,填充为0
df_clean['Endorsements_clean'].fillna(0, inplace=True)

# 处理Sport列缺失:如果无法推断,可以填充为'Unknown'
df_clean['Sport'].fillna('Unknown', inplace=True)

2.4 数据类型最终确认

清洗完毕后,确保每列都是合适的数据类型,这对后续的分组、排序操作至关重要。

# 转换Rank和Year为整数
df_clean['Rank'] = pd.to_numeric(df_clean['Rank'], errors='coerce').astype('Int64')  # 使用可空整数类型
df_clean['Year'] = pd.to_numeric(df_clean['Year'], errors='coerce').astype('Int64')

# 再次确认数据类型
print(df_clean.dtypes)

3. 核心分析功能实现

数据清洗干净后,我们就可以像搭积木一样,构建各种分析功能。原始需求中提到了两个核心功能:查询某年的前K名运动员,以及按运动项目筛选并统计总收入。我们用Pandas来实现,你会发现代码比纯Python列表操作简洁、高效得多。

3.1 查询指定年份的收入前K名

这个功能本质上是按年份筛选 -> 按收入排序 -> 取前K条。Pandas的链式调用让这个过程非常直观。

def get_top_k_athletes(year, k, df):
    """
    获取指定年份收入排名前K的运动员信息。

    参数:
    year (int): 年份,范围2012-2019
    k (int): 需要返回的排名数量
    df (DataFrame): 清洗后的运动员收入DataFrame

    返回:
    DataFrame: 包含前K名运动员信息的DataFrame
    """
    # 输入验证
    if year not in range(2012, 2020):
        raise ValueError("年份必须在2012到2019之间。")
    if k <= 0:
        raise ValueError("k必须为正整数。")

    # 筛选、排序、取前K
    result = df[df['Year'] == year]\
               .sort_values(by='Pay_clean', ascending=False)\
               .head(k)\
               .copy()

    # 重置索引,并生成从1开始的排名(注意:原始Rank列可能因筛选而乱序,我们按收入重新排)
    result.reset_index(drop=True, inplace=True)
    result.index = result.index + 1  # 索引从1开始,更符合“排名”的直观感受

    return result

# 使用示例:获取2019年收入前5的运动员
top_5_2019 = get_top_k_athletes(2019, 5, df_clean)
print("2019年收入前5名运动员:")
# 选择需要输出的列,并格式化货币显示
display_columns = ['Name', 'Pay_clean', 'Salary_clean', 'Endorsements_clean', 'Sport', 'Year']
top_5_display = top_5_2019[display_columns].copy()
top_5_display['Pay_clean'] = top_5_display['Pay_clean'].apply(lambda x: f'${x:.1f} M')
top_5_display['Salary_clean'] = top_5_display['Salary_clean'].apply(lambda x: f'${x:.1f} M')
top_5_display['Endorsements_clean'] = top_5_display['Endorsements_clean'].apply(lambda x: f'${x:.1f} M')
print(top_5_display.to_string())

3.2 按运动项目查询与统计

这个功能稍微复杂一些,涉及用户交互模拟(选择年份、显示运动项目菜单、选择项目)和分组聚合计算。我们将功能拆解为几个小函数。

首先,一个获取某年所有不重复运动项目并排序的函数:

def get_sport_menu_for_year(year, df):
    """
    获取指定年份数据中存在的所有运动项目,按字母排序并编号。

    参数:
    year (int): 年份
    df (DataFrame): 清洗后的DataFrame

    返回:
    dict: 编号到运动项目名称的映射字典
    list: 运动项目名称列表(已排序)
    """
    # 筛选指定年份的数据
    df_year = df[df['Year'] == year]

    # 获取不重复的运动项目,排序,忽略可能存在的'Unknown'
    sports = sorted([sport for sport in df_year['Sport'].unique() if sport != 'Unknown'])

    # 创建编号映射
    sport_menu = {idx+1: sport for idx, sport in enumerate(sports)}

    return sport_menu, sports

# 使用示例
menu_dict, sport_list = get_sport_menu_for_year(2019, df_clean)
print("2019年运动项目菜单:")
for num, sport in menu_dict.items():
    print(f"{num}: {sport}")

接下来,实现选择特定运动项目后,输出该年所有该项目的运动员并计算总收入:

def get_athletes_by_sport(year, sport_name, df):
    """
    获取指定年份、指定运动项目的所有运动员信息,并计算总收入。

    参数:
    year (int): 年份
    sport_name (str): 运动项目名称
    df (DataFrame): 清洗后的DataFrame

    返回:
    DataFrame: 该运动项目所有运动员的DataFrame(按收入降序排列)
    float: 该运动项目当年的总收入(百万美元)
    """
    # 筛选条件
    mask = (df['Year'] == year) & (df['Sport'] == sport_name)
    df_sport = df[mask].copy()

    if df_sport.empty:
        print(f"在{year}年未找到运动项目'{sport_name}'的相关数据。")
        return pd.DataFrame(), 0.0

    # 按收入降序排列
    df_sport_sorted = df_sport.sort_values(by='Pay_clean', ascending=False).reset_index(drop=True)
    df_sport_sorted.index = df_sport_sorted.index + 1  # 排名从1开始

    # 计算总收入
    total_income = df_sport_sorted['Pay_clean'].sum()

    return df_sport_sorted, total_income

# 使用示例:查询2019年Soccer项目
soccer_athletes_2019, total = get_athletes_by_sport(2019, 'Soccer', df_clean)
print(f"\n2019年 Soccer 项目运动员 (共{len(soccer_athletes_2019)}人):")
# 输出前几名看看
print(soccer_athletes_2019[['Name', 'Pay_clean', 'Sport']].head().to_string())
print(f"\n2019年 Soccer 项目总收入: ${total:.2f} M")

最后,我们可以将这些函数组合成一个模拟命令行交互的流程。虽然在实际Web应用或GUI中交互方式不同,但核心的数据处理逻辑是一致的。

4. 超越基础:深度分析与可视化探索

完成了基本查询功能,数据分析的乐趣才刚刚开始。这份数据集还能挖掘出许多有趣的洞察。下面,我们进行几个维度的深度分析。

4.1 历年总收入与运动员数量趋势

首先,我们看看这八年里,顶级运动员们的“蛋糕”是变大了还是变小了,以及分蛋糕的人有没有变多。

import matplotlib.pyplot as plt
import seaborn as sns
plt.style.use('seaborn-v0_8-darkgrid') # 设置一个好看的绘图风格

# 按年份分组聚合
yearly_stats = df_clean.groupby('Year').agg(
    Total_Pay=('Pay_clean', 'sum'),
    Athlete_Count=('Name', 'nunique'),
    Average_Pay=('Pay_clean', 'mean'),
    Median_Pay=('Pay_clean', 'median')
).reset_index()

print("历年统计摘要:")
print(yearly_stats.to_string())

# 绘制双轴趋势图
fig, ax1 = plt.subplots(figsize=(12, 6))

color = 'tab:blue'
ax1.set_xlabel('Year')
ax1.set_ylabel('Total Pay (Million $)', color=color)
line1 = ax1.plot(yearly_stats['Year'], yearly_stats['Total_Pay'], marker='o', color=color, linewidth=2, label='Total Pay')
ax1.tick_params(axis='y', labelcolor=color)

ax2 = ax1.twinx()  # 共享x轴
color = 'tab:red'
ax2.set_ylabel('Number of Athletes', color=color)
line2 = ax2.plot(yearly_stats['Year'], yearly_stats['Athlete_Count'], marker='s', color=color, linestyle='--', label='Athlete Count')
ax2.tick_params(axis='y', labelcolor=color)

# 添加图例
lines = line1 + line2
labels = [l.get_label() for l in lines]
ax1.legend(lines, labels, loc='upper left')

plt.title('Trend of Total Pay and Athlete Count (2012-2019)')
fig.tight_layout()
plt.show()

4.2 收入结构分析:工资 vs. 代言

不同运动项目的收入构成差异巨大。篮球明星可能工资占大头,而高尔夫球手则更依赖代言。我们可以用分组柱状图来直观展示。

# 选取一个代表性年份,比如2019年
df_2019 = df_clean[df_clean['Year'] == 2019].copy()

# 计算每个运动项目的平均工资占比
df_2019['Salary_Ratio'] = df_2019['Salary_clean'] / df_2019['Pay_clean']

# 选取运动员数量较多的前10个运动项目进行分析
top_sports = df_2019['Sport'].value_counts().head(10).index.tolist()
df_top_sports = df_2019[df_2019['Sport'].isin(top_sports)]

# 分组计算平均工资占比和平均代言占比
sport_income_composition = df_top_sports.groupby('Sport').agg(
    Avg_Salary_Ratio=('Salary_Ratio', 'mean'),
    Avg_Endorsement_Ratio=('Salary_Ratio', lambda x: 1 - x.mean()) # 代言占比 = 1 - 工资占比
).sort_values('Avg_Salary_Ratio', ascending=False)

print("\n2019年各运动项目收入构成(平均):")
print(sport_income_composition)

# 绘制横向堆叠柱状图
fig, ax = plt.subplots(figsize=(10, 8))
sport_income_composition[['Avg_Salary_Ratio', 'Avg_Endorsement_Ratio']].plot(
    kind='barh',
    stacked=True,
    color=['steelblue', 'lightcoral'],
    ax=ax
)
ax.set_xlabel('Income Ratio')
ax.set_title('Average Income Composition by Sport (2019)')
ax.legend(['Salary/Winnings', 'Endorsements'])
plt.tight_layout()
plt.show()

4.3 头部运动员的统治力分析:帕累托效应

我们常听说“二八定律”,在运动员收入领域是否也适用?我们可以计算前10%、前20%的运动员占据了总收入的多少比例。

def analyze_pareto(df, year):
    """分析指定年份收入的帕累托分布(头部效应)"""
    df_year = df[df['Year'] == year].sort_values('Pay_clean', ascending=False).reset_index(drop=True)
    df_year['Cumulative_Pay'] = df_year['Pay_clean'].cumsum()
    df_year['Cumulative_Pct'] = df_year['Cumulative_Pay'] / df_year['Pay_clean'].sum() * 100
    df_year['Athlete_Pct'] = (df_year.index + 1) / len(df_year) * 100

    # 找出关键点:例如,前20%的运动员贡献了多少收入?
    idx_20pct = int(len(df_year) * 0.2)
    income_20pct = df_year.loc[idx_20pct - 1, 'Cumulative_Pct'] if idx_20pct > 0 else 0

    # 找出收入占比超过50%需要多少名运动员
    athletes_for_50pct = df_year[df_year['Cumulative_Pct'] >= 50].index[0] + 1
    pct_for_50pct = athletes_for_50pct / len(df_year) * 100

    return {
        'year': year,
        'total_athletes': len(df_year),
        'top_20pct_income_share': income_20pct,
        'athletes_for_50pct_income': athletes_for_50pct,
        'pct_athletes_for_50pct': pct_for_50pct,
        'detail_df': df_year[['Name', 'Pay_clean', 'Cumulative_Pct', 'Athlete_Pct']]
    }

# 对每一年进行分析
pareto_results = {}
for year in range(2012, 2020):
    pareto_results[year] = analyze_pareto(df_clean, year)

# 汇总结果到DataFrame
pareto_summary = pd.DataFrame([pareto_results[y] for y in range(2012, 2020)])
print("\n历年收入帕累托效应分析:")
print(pareto_summary[['year', 'total_athletes', 'top_20pct_income_share', 'pct_athletes_for_50pct']].to_string())

# 可视化其中一年的详细洛伦兹曲线
year_to_plot = 2019
detail_df = pareto_results[year_to_plot]['detail_df']
plt.figure(figsize=(10, 6))
plt.plot(detail_df['Athlete_Pct'], detail_df['Cumulative_Pct'], marker='.', linewidth=2, label=f'Lorenz Curve ({year_to_plot})')
plt.plot([0, 100], [0, 100], 'k--', alpha=0.5, label='Perfect Equality Line')
plt.fill_between(detail_df['Athlete_Pct'], 0, detail_df['Cumulative_Pct'], alpha=0.3)
plt.xlabel('Percentage of Athletes (%)')
plt.ylabel('Percentage of Total Income (%)')
plt.title(f'Income Concentration (Lorenz Curve) - {year_to_plot}')
plt.legend()
plt.grid(True, alpha=0.3)
plt.show()

这个分析能清晰地揭示收入的集中程度。如果曲线越陡峭(越远离对角线),说明收入越集中在少数顶级运动员手中。

4.4 运动项目的“造富”能力变迁

哪些运动项目在八年里整体收入增长最快?哪些项目的顶级运动员收入最高?我们可以通过一个数据透视表(Pivot Table)和热力图来观察。

# 创建透视表:行是运动项目,列是年份,值是该运动项目当年所有运动员的总收入
pivot_total = pd.pivot_table(df_clean,
                             values='Pay_clean',
                             index='Sport',
                             columns='Year',
                             aggfunc='sum',
                             fill_value=0)

# 计算每个运动项目2019年相对于2012年的收入增长率
pivot_total['Growth_Rate'] = (pivot_total[2019] - pivot_total[2012]) / pivot_total[2012] * 100
pivot_total['Growth_Rate'].replace([np.inf, -np.inf], np.nan, inplace=True) # 处理除零错误

print("\n各运动项目总收入(2012 vs 2019)及增长率(%):")
# 筛选出2012年收入大于一定阈值(比如10M)的项目,避免基数太小导致的增长率失真
significant_sports = pivot_total[pivot_total[2012] > 10].copy()
print(significant_sports[[2012, 2019, 'Growth_Rate']].sort_values('Growth_Rate', ascending=False).head(10).to_string())

# 可视化:热力图展示各运动项目历年总收入变化
plt.figure(figsize=(14, 10))
# 对数值进行对数变换,使颜色对比更明显(因为收入差异可能很大)
sns.heatmap(pivot_total.iloc[:, :-1].applymap(lambda x: np.log10(x+1)), # +1避免log(0)
            cmap='YlOrRd',
            linewidths=0.5,
            cbar_kws={'label': 'Log10(Total Income + 1)'})
plt.title('Heatmap of Total Income by Sport and Year (Log Scale)')
plt.ylabel('Sport')
plt.xlabel('Year')
plt.tight_layout()
plt.show()

做完这些分析,你手里这份静态的数据文件就“活”了起来。你不仅能回答“谁在2019年赚得最多”这种基础问题,还能洞察到收入结构的行业差异、头部效应的强弱变化,以及不同运动项目的商业价值变迁趋势。这些才是数据分析真正产生价值的地方。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐