ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python数据分析实战:CBA球员数据可视化系统设计与实现

Python数据分析实战:CBA球员数据可视化系统设计与实现 说句实在话当我把项目标题写成“Pyuthon的CBA篮球球员数据可视化分析系统的设计与实现”的时候我自己都没绷住——Python拼成了Pyuthon这大概就是项目初期敲字太快的真实写照。但这个项目本身却是我认认真真从爬虫、清洗、分析到可视化做下来的完整流程。简单说这个系统做的事情就是用Python抓取CBA球员的比赛数据清洗成规整的结构化数据然后通过图表把得分、篮板、助攻、效率值这些指标展示到网页上让球迷不只看集锦也能看到数字背后的东西。如果你正在学Python数据分析或者对篮球数据感兴趣想自己动手做点东西这篇文章的完整链路和踩坑记录应该能帮你省下不少时间。1. 项目思路为什么选CBA球员数据以及技术栈怎么定1.1 切入点为什么是CBA球员数据那阵子我刚好在练Python数据分析练手项目大多是房价预测、天气爬虫做多了总觉得有点“为做而做”。有一天在虎扑看到球迷争论“郭艾伦和赵继伟谁更稳”评论区翻来覆去就是“打法不一样”“位置不同没法比”。我就想能不能用数据把这种问题拆开选CBA球员数据做切入有几个实际考量。第一数据源相对开放。CBA官网、各大体育数据网站都有大量比赛统计不需要像金融数据那样搞复杂的接口授权直接爬公开页面就行。第二数据规模适中。整个联盟球员也就两三百人每人几十个字段这个体量用一台普通电脑、用Pandas处理起来非常轻松不会一上来就被性能问题劝退。第三业务逻辑清晰。得分、篮板、助攻、抢断、盖帽、命中率这些指标任何一个懂点篮球的人都知道是什么意思做出来的图表不需要额外解释成本。这三个特点叠加在一起意味着我可以把全部精力放在“怎么爬干净”“怎么算准确”“怎么展示直观”这些技术问题上而不是先花两周琢磨业务到底怎么建模。另外还有一个隐性好处这类体育数据项目特别适合作为个人作品。它既有数据采集又有数据处理又有可视化展示链路完整放在简历上或者面试作品集里比单纯跑一个鸢尾花分类要有记忆点得多。1.2 技术选型Python Flask ECharts 的组合逻辑这个项目的技术栈一句话总结就是Python管数据Flask管后端ECharts管图表。为什么这么选每一步都有它具体的理由。先说Python。数据分析生态里Python基本是默认选项Pandas处理表格数据、NumPy做计算、Requests和BeautifulSoup做爬虫一套体系下来不需要换语言学习成本和开发成本都能压住。如果换Java或者C光是用表格类库就得折腾半天。再说Flask而不是Django。这个项目本质上就是“几个页面展示几张图”不是复杂业务系统。Flask轻量单文件就能起服务加上Jinja2模板引擎直接渲染HTML和ECharts配合非常自然。Django虽然功能全但对于这种个人项目有点重配置耗时也更长。我当时花十分钟初始化一个Flask应用剩下的时间全扑在数据和图表上效率高得多。最后是ECharts而不是Matplotlib。这是很多初学者最容易选错的地方。Matplotlib做静态图表很稳但它是Python库画完图输出PNG想和网页交互基本没戏。ECharts是纯前端的JavaScript图表库鼠标悬停有提示、点击图例能筛选还能动态加载数据和Flask模板配合起来用户拿到的是一个可以“玩”的页面而不是一张死图。项目名叫“可视化分析系统”重点在“系统”两个字那就必须有交互所以ECharts是更合理的选择。整个系统架构也不复杂爬虫脚本把数据抓下来存成CSVFlask读取CSV处理后通过模板变量传给前端ECharts接收JSON数据渲染图表。数据层、应用层、展示层各管各的后期想换数据源或者加图表都不至于推倒重来。2. 数据获取与清洗整个项目最“苦”的环节2.1 数据源选型和爬虫实现细节数据源我对比过两三个官网站点数据最权威但页面结构经常调整解析逻辑容易失效综合体育网站结构相对稳定数据字段也全最后我选了后者。这里提醒一句爬数据之前先看一下目标网站的robots协议和用户协议个人学习用途控制好请求频率就行但尊重对方的规则是底线别把你的IP搞进黑名单也别给人家服务器添麻烦。爬虫技术方案用的是Requests BeautifulSoup的组合。先发请求拿HTML再用定位标签的方式提取表格。核心代码大概长这样import requests from bs4 import BeautifulSoup headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://sports.sina.com.cn/cba/ } def fetch_player_stats(url): resp requests.get(url, headersheaders, timeout10) resp.raise_for_status() soup BeautifulSoup(resp.text, html.parser) rows [] table soup.select_one(table.stat_table) for tr in table.select(tr)[1:]: tds tr.find_all(td) if len(tds) 10: continue rows.append([td.get_text(stripTrue) for td in tds]) return rows这里有几个特别值得注意的细节。一个是User-Agent必须设置。默认的Python-requests UA很多站点直接拒绝模拟浏览器UA能绕过大部分基础拦截。Referer同样要填一些站点会校验你是不是从它的页面内部跳转来的。另一个是解析时先锁定表格容器。如果你直接find_all(tr)很可能会把表头、统计说明、广告之类的无关行也抓进来。我先精确选择table.stat_table再跳过首行表头出来的就是干净的纯数据行。每行字段不够10个的直接丢弃这一手能过滤掉不少合并单元格造成的残缺行。再补充一个动态加载问题的处理。有些数据页面是Ajax后加载的Requests直接拿到的HTML里表格是空的。这种情况两种解法一是用浏览器开发者工具里的Network面板找到真实的XHR接口直接请求那个JSON接口效率最高二是上Selenium模拟浏览器渲染省事但慢。我这边目标页面是服务端渲染的所以Requests就够用了。最后是请求节奏。我在循环抓取时加了随机延时1到3秒之间既不给对方压力也避免被识别成脚本请求。实测下来整个赛季数据抓完大约需要两三分钟完全可以接受。2.2 Pandas清洗流程三步把脏数据变成可用DataFrame爬下来的数据是二维列表一堆字符串混着空值直接分析肯定不行。清洗这一步我踩了不少坑整理出来其实就三个核心动作规范列名、类型转换、缺失值处理。第一步是构造DataFrame并给列名。爬虫拿到的表头有时候带空格、有时候是“得分(场均)”这种带括号的写法直接用会很难受。我统一改成简洁的英文字段player,team,games,minutes,points,rebounds,assists,steals,blocks,fg_pct,three_pt_pct,ft_pct。别小看这一步字段名规范了后面写筛选和计算代码都能少打好多字。第二步是类型转换。爬下来的数据是“19.8”“4.5”这种字符串直接做加减会报错。用pd.to_numeric配合errorscoerce能优雅处理import pandas as pd df pd.DataFrame(raw_rows, columnscolumns) numeric_cols [minutes, points, rebounds, assists, steals, blocks, fg_pct, three_pt_pct, ft_pct] for col in numeric_cols: df[col] pd.to_numeric(df[col], errorscoerce)errorscoerce的意思是遇到转不了的值就置成NaN而不是直接报异常终止程序。这在真实数据里太常见了比如某些球员三分命中率显示的是“--”意思是他压根没投过三分这种值转成NaN才是合理的不应该当成0也不该让程序崩溃。第三步是缺失值处理。处理原则要跟业务挂钩对于出场次数少于5场的球员样本量太小场均数据参考意义不大我直接过滤掉对于单项技术统计全空的比如前面说的三分命中率我用0填充。注意填充成0和过滤掉是两码事过滤针对的是整行可靠性填充针对的是单项缺失逻辑别搞混。清洗完的数据我额外加了一个计算字段season记录数据所属赛季。这一步就是纯预防性的万一后续要对比两个赛季的球员表现有这个字段就不用回头重新爬了。3. 可视化指标与图表设计别把图表堆成仪表盘3.1 指标怎么选、怎么算从基础数据到PER效率值可视化最忌讳的就是把十几个指标全部堆到一张图上最后变成谁也看不明白的“色谱图”。我做这套系统时给自己定了一条规矩每一张图只回答一类问题。第一类是基础能力问题用得分、篮板、助攻、抢断、盖帽这五项这是篮球迷最熟悉的统计维度直接上雷达图对比两个球员非常直观。但这五项数据彼此量纲不同得分可能场均25分盖帽场均却只有2个雷达图画出来得分维度会无限外扩盖帽维度缩成一个小尖角根本没法看。所以可视化之前一定要做归一化处理把每个指标映射到0到1区间。我当时用的是最大最小值归一化def normalize(series): return (series - series.min()) / (series.max() - series.min())这样处理之后五项指标在雷达图上都是0到1的尺度形状才能真实反映球员的能力结构。第二类是效率问题光看得分高不等于效率高一个球员场均出手20次拿25分和场均为10次拿15分哪个更值得夸奖这时候要用真实命中率和效率值这类进阶指标。Efficiency效率值我用了简化版公式PER ≈ (得分 篮板 助攻 抢断 盖帽) / 出场次数这个简化版虽然没法衡量出手效率但作为业余项目已经完全够用。更细一点是遵循一种流行的简化计算公式得分 篮板 助攻 抢断 盖帽 - 失误除以出场次数能直接反映球员单场综合贡献。我把这个字段算好后直接作为散点图的X轴或Y轴来分析“球员贡献效率”和“场均得分”之间的关系。第三类是全面性判断一个球员是不是“什么都会一点”的万金油还是“单项爆炸”的得分狂魔这类问题适合用雷达图的能力覆盖范围来看。3.2 ECharts双图实现雷达图 散点图的实战配置设计完指标接下来就是把它们落到ECharts上。我用了一页双图的结构上半部分放雷达图用于单个球员的能力截面分析下半部分放散点图用于全联盟球员的效率-得分分布分析。先看雷达图的ECharts配置。这一个配置看起来简单实际有几个容易踩坑的地方var radarOption { radar: { indicator: [ { name: 得分, max: 1 }, { name: 篮板, max: 1 }, { name: 助攻, max: 1 }, { name: 抢断, max: 1 }, { name: 盖帽, max: 1 } ], radius: 65% }, series: [{ type: radar, data: [{ value: [0.82, 0.45, 0.91, 0.33, 0.12], name: 某球员, areaStyle: { opacity: 0.15 } }] }] };max全部设置成1因为我已经在Pandas里做了归一化如果不归一化而直接用原始值就必须把max改成各指标的实际最大值否则雷达图形状会失真。areaStyle.opacity设为0.15加了透明的填充面积对比多个球员时视觉区分度会高很多。散点图这里我选择用得分做X轴PER效率值做Y轴var scatterOption { xAxis: { name: 场均得分, type: value }, yAxis: { name: 效率值PER, type: value }, series: [{ type: scatter, symbolSize: 10, data: [[19.8, 21.5], [12.3, 15.2], [25.1, 28.7]] }] };散点图不需要归一化原始数值直接映射坐标轴反而更符合人的直觉。拿到这张图之后右上角的球员就是“高分且高效”左下角是“低分且低效”这种一目了然的定位效果是表格完全给不了的。有一件事需要注意ECharts的data数组要求是纯JavaScript数据。Pandas处理完之后我用df.to_json(orientvalues)拿到JSON字符串再在Flask里注入到模板变量。这个过程中最容易出错的是中文乱码和数据格式不一致后面第五章我会专门说排查方式。4. 系统整体实现Flask ECharts的完整代码链路4.1 代码结构与核心模块划分项目做到这一步数据链路已经通了接下来要把整个系统Imagine成产品。我最终的目录结构是这样的cba-visualization/ ├── app.py ├── data/ │ ├── cba_players.csv │ └── preprocess.py ├── templates/ │ └── index.html └── static/ ├── echarts.min.js └── style.csspreprocess.py负责数据清洗和指标计算输出干净的CSVapp.py负责Flask路由和把数据传给模板index.html负责页面结构和图表渲染。为什么要把爬虫和预处理的逻辑单独拆出去而不是塞进app.py核心原因是不想让Web服务启动时每次都重新爬一遍数据。数据是每天更新的清洗结果存在CSV里Flask读取的是处理好的成品启动速度快得多也方便调试。app.py的核心代码非常简洁from flask import Flask, render_template import pandas as pd app Flask(__name__) app.route(/) def index(): df pd.read_csv(data/cba_players.csv) top_scorers df.nlargest(10, points)[ [player, team, points, rebounds, assists] ].to_dict(records) player 某球员 player_data df[df[player] player].iloc[0] radar_values [ normalize_single(player_data[points], df), normalize_single(player_data[rebounds], df), normalize_single(player_data[assists], df), normalize_single(player_data[steals], df), normalize_single(player_data[blocks], df) ] scatter_data df[[points, per]].dropna().values.tolist() return render_template( index.html, top_scorerstop_scorers, radar_valuesradar_values, scatter_datascatter_data )这段代码里有一个关键设计nlargest(10, points)先取出得分前10的球员做Table排名展示既满足球迷关注“得分王”的需求又不用把全部几百条数据堆在页面上。然后雷达图固定显示某一位重点球员的能力结构散点图则放全量数据。三层内容各自履行不同职责页面信息密度刚好。4.2 页面渲染和数据传递的关键细节Flask把Python数据传到前端模板的方式是通过Jinja2模板引擎的变量替换。在index.html里接收这些变量的写法是div idradar stylewidth: 48%; height: 400px; display: inline-block;/div div idscatter stylewidth: 48%; height: 400px; display: inline-block;/div script var radarValues {{ radar_values | tojson }}; var scatterData {{ scatter_data | tojson }}; /script这里有没有| tojson过滤器结果完全不同。如果直接写成{{ radar_values }}Python列表会被渲染成JavaScript无法直接解析的字符串形式比如把单引号当字符串边界导致控制台报错。加上| tojson之后Flask会把数据序列化成标准JSONJavaScript端直接就能用。这个细节是我调试过程中踩过的最隐形的一个坑一定要记住。HTML模板里图表容器的style也不能忽略。ECharts初始化时要求容器有明确的宽度和高度如果div默认高度为0图表画出来就是一个空白块。我当时先设置了height: 400px图表立刻正常显示。图表初始化脚本统一放在页面底部等DOM加载完成之后再执行var radarChart echarts.init(document.getElementById(radar)); radarChart.setOption({ radar: { indicator: [{ name: 得分, max: 1 }, /* ... */] }, series: [{ type: radar, data: [{ value: radarValues }] }] }); var scatterChart echarts.init(document.getElementById(scatter)); scatterChart.setOption({ xAxis: { name: 场均得分, type: value }, yAxis: { name: 效率值PER, type: value }, series: [{ type: scatter, data: scatterData, symbolSize: 10 }] });这一切跑通之后打开http://127.0.0.1:5000页面上左边是球员能力雷达图右边是联盟得分效率散点图下面是得分榜前10的排名表格一个能用、能看、能玩的可视化分析系统就算成型了。5. 实测遇到的问题与排查记录5.1 爬虫阶段的坑编码、反爬、动态内容加载这个项目最折腾的就是爬虫阶段反复踩坑的密集程度远超后面的可视化环节。第一个坑是页面编码。有些体育网站用的是GBK或者GB2312编码直接用BeautifulSoup解析会出现一串乱码。解决方式是在拿到响应文本后手动指定编码resp.encoding resp.apparent_encodingapparent_encoding是Requests库根据响应内容自动推测的编码中文页面用它基本不会错。如果不设置Requests会默认按ISO-8859-1处理那解析出来的中文全废了。这个错位的症状很迷惑——不是报错而是明明页面源码里看得见中文解析出来却是乱码排查方向很容易跑偏。第二个坑是防盗链。请求头里的Referer如果缺失部分网站会返回403或者一片空白的正常页面头。解决办法就是前面提到的把Referer设置成从站点内页跳转的来源。这不算绕过什么防护只是模拟普通浏览器的正常行为合规且合理。第三个坑是动态加载。有的数据页面首屏能看到球员名单但是详细统计数字是通过Ajax异步加载的Requests拿到的HTML里只有空壳。判断方法很简单在浏览器里右键查看源代码如果源代码里找不到你要的数据那就是动态加载。这个时候与其用Selenium等浏览器渲染不如从Network面板里找到真实的XHR请求地址直接模拟那个地址抓JSON效率完全不在一个数量级。5.2 可视化阶段的毛病图不显示、坐标轴拥挤、中文乱码图表阶段常见的三个问题我一次性梳理出来。第一个是图不显示页面是空白的。九成情况是div容器没有高度或者JavaScript报错。先打开浏览器开发者工具看Console如果有类型错误或者变量的值变成了undefined十有八九是Jinja2模板变量没转成JSON或者转出来的格式不对。确认方式是在浏览器里直接查看最终渲染的HTML代码看radarValues那行到底输出的是什么格式。第二个是坐标轴太拥挤。散点图把所有球员的数据都画上去之后横坐标标签密密麻麻连成一片甚至互相重叠。这类问题用两个方案解决一个是设置axisLabel.interval为auto让ECharts自动跳过部分标签另一个是坐标轴name用简短的“得分”而不用“平均每场得分”从源头上减少文字宽度。实测下来interval: 0表示全部显示interval: auto会自动抽稀实际效果好了很多。第三个是中文乱码。这有两种情况。一种发生在HTML页面本身页面里的中文全部变成问号一般是文件保存编码不是UTF-8在meta charsetutf-8也救不了。另一种发生在从Python传过来的球员名字上我在Flask里读取CSV时光标默认编码可能不是UTF-8处理方式是在pd.read_csv时显式写上encodingutf-8。顺手再加一个兜底参数errorsreplace即使是脏数据也不会让页面崩掉。5.3 一份问题排查速查表直接照着抄我把前面提到的问题整理成一张速查表做同类项目时直接按表排查能省很多事。现象可能原因排查顺序与解法爬到的中文全是乱码响应编码识别错误设置resp.encoding resp.apparent_encoding请求返回403缺少UA或Referer在请求头里补全浏览器UA和来源页爬到的表格为空数据是Ajax动态加载打开F12 Network找XHR真实接口页面图表空白div没有高度或JS报错先给div设置height: 400px再查Console模板变量显示为字符串Flask数据未转JSON加坐标轴标签重叠标签密度过高设置axisLabel.interval: auto图表区域显示NaNDataFrame混合类型用pd.to_numeric(..., errorscoerce)清洗这张表虽然是针对CBA项目整理的但换一个数据领域也照样能用。因为爬虫、数据处理、前端可视化这三层架构的问题往往都是通用的只是表现形式不同而已。6. 项目做完之后的一点体会这个项目从标题里的“Pyuthon”拼错开始到页面上图表真的跑起来为止前后花了差不多两周的业余时间。我个人最大的体会是数据可视化项目的难点从来不在“画图”这个动作上而在数据链路是否可靠、指标计算是否合理、页面交互是否顺手。ECharts官方文档写了成百上千个配置项但真正决定项目质量的是你拿什么数据喂给它、这个数据代表什么业务含义。还有一个小技巧想分享当时我在做这个项目时先把所有球员数据打印成表格对着原始数据手动猜了猜“谁应该效率高谁应该得分低”再去看图表是不是符合直觉。这个过程看似多余却能最快暴露数据清洗和公式计算的问题。你手里有真实业务的参照而不是对着代码空想排查效率高很多。如果你也准备做一个类似的系统建议先想清楚一个问题你想用数据回答什么。问题越具体图表选型、指标设计、页面布局都会水到渠成。
返回列表