多维聚合实战:Pandas与SQL的交叉分析心法
1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品大类看毛利同时还要对比去年同期财务团队需要把成本拆解到“部门-项目-费用类型-发生月份”四个维度再筛选出超预算的组合甚至一个简单的用户行为分析都要交叉统计“新老用户 × 设备类型 × 页面路径深度 × 当日活跃时段”。这时候Excel 的透视表点到第三层就开始卡顿SQL 里写个 GROUP BY 加上 CASE WHEN 嵌套三层自己都快看不懂了——这已经不是“汇总”问题而是多维聚合Multi-Dimensional Aggregation的实战现场。本篇标题中的 “Part 20: Data Manipulation in Multi-Dimensional Aggregation”绝非教科书里抽象的“高维数组”概念它直指现代数据分析中一个最硬核、也最容易被低估的环节如何在保留原始数据颗粒度的前提下自由、高效、可复现地对多个维度进行任意组合、切片、钻取与比较。核心关键词——多维聚合、数据操作、维度建模、OLAP思维、分组聚合、交叉分析——全部围绕一个现实目标让数据从“静态报表”变成“可交互的决策仪表盘”。它适合三类人一是刚从单表 GROUP BY 过渡到业务宽表开发的 SQL 工程师二是用 Pandas 做分析但总被pivot_table参数绕晕的 Python 数据分析师三是正在搭建 BI 系统、需要理解底层聚合逻辑的产品或数仓工程师。这不是讲理论而是拆解我在真实项目中处理过 12TB 日志、支撑 37 个业务方自助分析需求时反复打磨出的一套“多维数据操作心法”。2. 多维聚合的本质为什么不能只靠 GROUP BY 和嵌套子查询2.1 传统 SQL 聚合的“维度陷阱”很多人一上来就写SELECT region, product_category, quarter, SUM(revenue) AS total_revenue, AVG(profit_margin) AS avg_margin FROM sales_fact GROUP BY region, product_category, quarter;看起来没问题错。这只是“固定维度组合”的快照。一旦业务方问“给我看看华东地区手机类目下Q1 各个月份的环比增长”你就得重写 SQL加EXTRACT(MONTH FROM sale_date)再套一层窗口函数LAG()。更麻烦的是如果他们接着问“那华北地区电脑类目呢能不能和华东手机放一张表对比”——你立刻陷入“维度爆炸”每新增一个维度组合就要写一条新 SQL维护成本指数级上升。我曾接手一个遗留系统光是“区域×产品×时间”的组合报表就有 42 张独立视图其中 31 张逻辑高度重复仅因 WHERE 条件不同而硬生生拆开。这就是典型的“维度耦合”把分析逻辑要什么维度和存储结构表怎么建绑死在一起。提示真正的多维聚合核心不是“算得快”而是“算得活”。它要求聚合结果本身具备“维度可插拔”能力——就像乐高积木region、product、time 这些维度是独立模块你可以随时拼成 region×product也可以拆开只看 time 的趋势还能叠加一个 new_customer_flag 做二次过滤。2.2 OLAP 的立方体思维从“平面表格”到“数据立方体”多维聚合的底层模型其实是OLAP联机分析处理中的Cube数据立方体概念。想象一个三维空间X 轴是地区华东、华北、华南Y 轴是产品手机、电脑、配件Z 轴是时间Q1、Q2、Q3、Q4。每个坐标点 (华东, 手机, Q1) 就是一个“单元格”里面存着该组合下的销售额总和。这个立方体有三个关键特性上卷Roll-up从细粒度向上聚合。比如把“华东-手机-Q1-1月”、“华东-手机-Q1-2月”、“华东-手机-Q1-3月”三个单元格合并成“华东-手机-Q1”这个更高层级的单元格。这对应 SQL 的GROUP BY region, product, quarter。下钻Drill-down从粗粒度向下展开。比如点击“华东-手机-Q1”总值立刻看到其下 1月、2月、3月的明细。这要求原始事实表必须保留到“月”这一粒度不能只存到“季度”。切片Slice与切块Dice切片是固定一个维度看其他维度比如“只看 Q1 的所有数据”固定 Z 轴切块是同时固定多个维度比如“只看华东地区、手机类目的所有时间数据”固定 X 和 Y 轴。关键来了传统 SQL 的 GROUP BY 只能做一次上卷生成一个固定的二维结果集而多维聚合的目标是构建一个能支持任意上卷、下钻、切片的“活立方体”。这就引出了两个主流技术路径预计算Pre-aggregation和即席计算Ad-hoc Computation。2.3 预计算 vs 即席计算选哪条路取决于你的数据规模与灵活性需求维度预计算方案如 Cube 构建、物化视图即席计算方案如 Pandas、Dask、ClickHouse核心思想在数据入库后、查询前预先按常见维度组合计算好聚合结果并存库查询时实时从明细数据出发动态执行 GROUP BY 等操作响应速度毫秒级查预存结果秒级到分钟级取决于数据量与集群资源灵活性低。只能查预定义的维度组合新增组合需重建 Cube高。任意维度组合、任意过滤条件、任意计算逻辑均可实时执行存储成本高。一个 10 亿行事实表按 5 个维度两两组合可能生成 20 张物化表低。只存原始明细无额外聚合存储适用场景固定报表、BI 看板、高频访问的 TOP N 分析探索性分析、临时需求、A/B 测试、需要复杂自定义指标的场景我在一个电商中台项目做过实测一张 80 亿行的订单明细表order_id, user_id, sku_id, region, category, order_date, amount用 ClickHouse 即席查询GROUP BY region, category, toYearMonth(order_date)平均耗时 1.8 秒而用 Apache Kylin 构建相同 Cube首次构建耗时 47 分钟但后续查询稳定在 80ms。但当业务方突然提出“按用户新老标签new_user_flag和支付方式alipay/wechat/cash交叉分析”Kylin 方案必须停服、改 Schema、重新构建而 ClickHouse 一行 SQL 就搞定SELECT new_user_flag, payment_method, COUNT(*) FROM orders WHERE order_date 2024-01-01 GROUP BY new_user_flag, payment_method。注意没有银弹。我的经验是——如果 80% 的查询集中在 5 个以内固定维度组合且对延迟敏感 500ms优先预计算如果需求变化频繁、探索性强、或需要与机器学习特征工程联动则即席计算是唯一选择。本篇聚焦的 “Data Manipulation”正是即席计算场景下如何用代码优雅、高效、可复用地实现多维聚合。3. 核心操作解析Pandas 与 SQL 的双轨实践3.1 Pandas从groupby到pivot_table再到crosstab的进阶地图Pandas 是 Python 数据分析的基石但它的多维聚合能力常被严重低估。很多人只会df.groupby([A,B]).sum()这其实只是“扁平化分组”离真正的多维操作还差一层。3.1.1groupby的隐藏技能agg与named_agg实现多指标、多函数聚合基础groupby只能对所有列应用同一函数但业务需求永远是混合的销售额求和、订单数计数、客单价求平均、退货率算比例。这时agg就是救星# 错误示范写四次 groupby效率极低 sales_sum df.groupby([region,category])[revenue].sum() order_cnt df.groupby([region,category])[order_id].count() avg_amt df.groupby([region,category])[revenue].mean() # 正确示范一次 groupby多列多函数 result df.groupby([region,category]).agg({ revenue: [sum, mean], # 对 revenue 列同时算 sum 和 mean order_id: count, # 对 order_id 列只算 count quantity: sum, is_returned: mean # is_returned 是 0/1 标签mean 即退货率 }) # 输出是 MultiIndex 列形如 (revenue, sum), (revenue, mean), (order_id, count)但列名太丑用named_aggPandas 0.25result df.groupby([region,category]).agg( total_revenue(revenue, sum), avg_revenue(revenue, mean), order_count(order_id, count), total_qty(quantity, sum), return_rate(is_returned, mean) ) # 输出是干净的扁平列名total_revenue, avg_revenue, ...实操心得named_agg不仅名字清爽更重要的是它强制你为每个指标赋予业务语义名称。我在带新人时会要求agg字典里的 key 必须是“业务指标名”而不是“字段名函数名”。比如revenue_sum是坏名字total_gmv总成交额才是好名字。这能极大提升代码可读性和后续 BI 取数的准确性。3.1.2pivot_table构建“维度-指标”二维矩阵的终极武器groupby输出是长表Long Format而业务方最爱看的是宽表Wide Format行是地区列是产品单元格是销售额。pivot_table就是为此而生# 最简用法index行维度columns列维度values指标aggfunc聚合函数 pivot_df df.pivot_table( indexregion, # 行地区 columnscategory, # 列产品类目 valuesrevenue, # 值销售额 aggfuncsum, # 如何聚合regioncategory 下可能有多行 fill_value0 # 空单元格填 0而非 NaN ) # 输出示例 # category 手机 电脑 配件 # region # 华东 12000 8500 3200 # 华北 9800 11200 2800 # 华南 15600 7300 4100但它远不止于此。pivot_table支持多索引MultiIndex行和列这才是多维聚合的灵魂# 行是 [region, quarter]列是 [category, is_new_user]形成 4D 切片 pivot_4d df.pivot_table( index[region, quarter], # 行两个维度 columns[category, is_new_user], # 列两个维度 valuesrevenue, aggfuncsum, fill_value0 ) # 输出的列索引是 MultiIndex(手机, True), (手机, False), (电脑, True), ... # 这就是“数据立方体”的一个切面3.1.3crosstab专治“分类变量交叉频次”的轻量级神器当你只需要统计两个分类变量的联合分布比如“用户性别 × 购买渠道”的人数crosstab比pivot_table更简洁、更快# 统计每个地区、每个产品类目的订单数频次 freq_table pd.crosstab( indexdf[region], columnsdf[category], valuesdf[order_id], # 可选指定值列不指定则默认计数 aggfunccount, # 可选默认就是 count marginsTrue # 可选添加行/列总计 )注意crosstab本质是pivot_table的语法糖但它的设计哲学是“极简主义”。我的原则是只要需求是“纯频次统计”无脑用crosstab一旦涉及求和、平均、自定义函数立刻切回pivot_table。3.2 SQL从基础 GROUP BY 到窗口函数与 CUBE/ROLLUP 的跃迁SQL 是数据工程师的母语但多数人只停留在 GROUP BY。要驾驭多维聚合必须掌握三大进阶武器。3.2.1GROUPING SETS一次查询输出多个维度组合这是标准 SQLPostgreSQL, SQL Server, Oracle的高级特性能让你一条 SQL 替代 N 条-- 想要同时得到 -- (1) region category 的汇总 -- (2) region 的汇总即只按 region -- (3) category 的汇总即只按 category -- (4) 全局汇总即不按任何维度 SELECT region, category, SUM(revenue) AS total_revenue, GROUPING(region) AS grp_region, -- 返回 0参与分组或 1未参与 GROUPING(category) AS grp_category FROM sales GROUP BY GROUPING SETS ( (region, category), -- 组合1 (region), -- 组合2 (category), -- 组合3 () -- 组合4空括号全局汇总 );输出中grp_region1且grp_category1的行就是全局汇总grp_region1且grp_category0的行就是只按 category 的汇总。你可以用CASE WHEN把这些“虚拟维度”翻译成业务友好的标签SELECT CASE WHEN GROUPING(region)1 AND GROUPING(category)1 THEN ALL WHEN GROUPING(region)1 THEN ALL_REGIONS_ || category WHEN GROUPING(category)1 THEN region || _ALL_CATEGORIES ELSE region || _ || category END AS dimension_label, SUM(revenue) AS total_revenue FROM sales GROUP BY GROUPING SETS ((region, category), (region), (category), ());3.2.2CUBE与ROLLUPGROUPING SETS 的快捷方式CUBE(a,b,c)等价于GROUPING SETS((a,b,c),(a,b),(a,c),(b,c),(a),(b),(c),())—— 即 a,b,c 三个维度的所有可能组合。ROLLUP(a,b,c)则是层级式上卷(a,b,c),(a,b),(a),()。它们让代码更短但牺牲了精确控制权。我的建议是初学者用CUBE/ROLLUP快速验证逻辑生产环境用GROUPING SETS确保每一行结果都可控、可解释。3.2.3 窗口函数在聚合结果上做“二次加工”多维聚合的终点不是数字而是洞察。比如“华东手机 Q1 的销售额在所有地区-产品组合中排第几”——这需要先聚合再排名WITH regional_cat_sales AS ( SELECT region, category, SUM(revenue) AS total_revenue FROM sales WHERE quarter Q1 GROUP BY region, category ) SELECT region, category, total_revenue, RANK() OVER (ORDER BY total_revenue DESC) AS sales_rank, -- 全局排名 RANK() OVER (PARTITION BY region ORDER BY total_revenue DESC) AS rank_in_region -- 每个地区内排名 FROM regional_cat_sales;PARTITION BY是关键它把结果集按region分成若干“窗口”RANK()函数在每个窗口内独立计算。这实现了“分组内的多维分析”是GROUP BY永远做不到的。4. 实战全流程从原始日志到可交互多维分析报告4.1 场景设定一个真实的电商用户行为分析项目我们有一张user_event_log表每天增量约 5000 万行字段包括event_time(timestamp)事件发生时间user_id(string)用户 IDevent_type(string)事件类型page_view, add_to_cart, purchasepage_path(string)页面路径/home, /list/phone, /detail/iphone15device_type(string)设备mobile, desktop, tabletis_new_user(boolean)是否新用户注册 7 天内session_id(string)会话 ID用于计算会话深度业务目标制作一份周报回答三个核心问题各设备类型下各事件类型的转化漏斗从浏览→加购→购买新老用户在各设备上的购买转化率差异/list/phone页面的跳出率只访问该页就离开的会话占比。4.2 步骤一数据清洗与维度标准化不可跳过的地基原始日志充满噪声page_path有大小写混用、多余斜杠、参数乱码device_type有 ios, android 等子类需归并到 mobileevent_time需提取week_of_year和hour_of_day。这步决定后续所有分析的准确性。import pandas as pd import re def clean_page_path(path): 标准化 page_path转小写、去首尾斜杠、去参数 if pd.isna(path): return unknown # 去掉 ? 后面的参数 path re.split(r\?, path)[0] # 去首尾斜杠 path path.strip(/) # 归一化常见路径 if re.match(r^/list/.*, path): return /list elif re.match(r^/detail/.*, path): return /detail elif path : return /home else: return path # 应用清洗 df[clean_path] df[page_path].apply(clean_page_path) df[device_type_std] df[device_type].map({ mobile: mobile, ios: mobile, android: mobile, desktop: desktop, tablet: tablet }).fillna(other) # 提取时间维度 df[event_date] pd.to_datetime(df[event_time]).dt.date df[week_of_year] pd.to_datetime(df[event_time]).dt.isocalendar().week df[hour_of_day] pd.to_datetime(df[event_time]).dt.hour注意清洗逻辑必须文档化、可复现。我在项目中会把所有clean_*函数写在一个data_cleaning.py文件里并用 pytest 写单元测试例如assert clean_page_path(/list/phone?refabc) /list。这是避免“分析结果漂移”的生命线。4.3 步骤二构建核心事实表Fact Table与维度表Dimension Table多维建模的第一步是区分“事实”和“维度”。user_event_log是事实表记录原子事件而device_type_std,clean_path,week_of_year等是维度。为了性能和可读性我会显式构建维度表# 维度表设备类型 dim_device pd.DataFrame({ device_type_std: [mobile, desktop, tablet, other], device_desc: [移动设备, 桌面端, 平板, 未知] }) # 维度表页面路径带层级 dim_page pd.DataFrame({ clean_path: [/home, /list, /detail, /cart, /checkout, unknown], path_level: [首页, 列表页, 详情页, 购物车, 结算页, 未知] }) # 事实表只保留关键字段和外键 fact_events df[[ user_id, session_id, event_type, clean_path, device_type_std, is_new_user, week_of_year, hour_of_day ]].copy()4.4 步骤三多维聚合实现三大业务问题4.4.1 问题一设备×事件类型的转化漏斗目标是得到一个 3×3 的矩阵行是设备列是事件单元格是该设备下该事件的总次数。# 方法1crosstab最简洁 funnel_base pd.crosstab( indexfact_events[device_type_std], columnsfact_events[event_type], valuesfact_events[user_id], aggfunccount, marginsTrue # 添加总计行/列 ) # 方法2pivot_table更灵活可加 fill_value funnel_pivot fact_events.pivot_table( indexdevice_type_std, columnsevent_type, valuesuser_id, aggfunccount, fill_value0 ) # 计算转化率以 page_view 为基数 funnel_rates funnel_pivot.div(funnel_pivot[page_view], axis0).round(3) # 输出 # event_type page_view add_to_cart purchase # device_type_std # desktop 1.000 0.215 0.087 # mobile 1.000 0.182 0.073 # tablet 1.000 0.156 0.0624.4.2 问题二新老用户×设备的购买转化率这里需要“购买转化率”purchase_count / page_view_count。不能直接用crosstab因为要两个指标的比值。# 先按维度分组聚合两个指标 user_device_stats fact_events.groupby([is_new_user, device_type_std]).agg( page_views(event_type, lambda x: (x page_view).sum()), purchases(event_type, lambda x: (x purchase).sum()) ).reset_index() # 计算转化率 user_device_stats[conv_rate] ( user_device_stats[purchases] / user_device_stats[page_views] ).round(4) # 用 pivot_table 展开成宽表便于 BI 取数 conv_pivot user_device_stats.pivot_table( indexis_new_user, columnsdevice_type_std, valuesconv_rate, fill_value0 ) # 输出 # device_type_std desktop mobile tablet # is_new_user # False 0.087 0.073 0.062 # True 0.125 0.098 0.0814.4.3 问题三/list 页面的跳出率跳出率 只访问 /list 页面的会话数 / 所有访问 /list 页面的会话数。这需要会话级别的聚合。# 第一步标记每个会话是否只访问了 /list session_paths fact_events[fact_events[clean_path] /list].groupby(session_id)[clean_path].nunique() # session_paths 是 {session_id: 访问的不同页面数} # 只访问 /list 的会话count 1 single_list_sessions set(session_paths[session_paths 1].index) # 第二步统计所有访问 /list 的会话总数 all_list_sessions fact_events[fact_events[clean_path] /list][session_id].nunique() # 第三步计算跳出率 bounce_rate len(single_list_sessions) / all_list_sessions if all_list_sessions 0 else 0 # 如果要按设备细分跳出率 list_sessions fact_events[fact_events[clean_path] /list][[session_id, device_type_std]].drop_duplicates() list_session_counts list_sessions.groupby(device_type_std).size() # 找出每个设备下只访问 /list 的会话 list_only_sessions fact_events[ (fact_events[clean_path] /list) (fact_events[session_id].isin(single_list_sessions)) ].groupby(device_type_std).size() bounce_by_device (list_only_sessions / list_session_counts).fillna(0).round(3) # 输出desktop 0.321, mobile 0.415, tablet 0.2894.5 步骤四结果交付与自动化让分析真正产生价值聚合结果不是终点而是服务的起点。我会将最终结果写入数据库并配置定时任务# 写入 PostgreSQL使用 SQLAlchemy from sqlalchemy import create_engine engine create_engine(postgresql://user:passhost:5432/db) # 写入宽表供 BI 工具直连 conv_pivot.to_sql(weekly_conv_rate, engine, if_existsreplace, indexTrue) # 写入明细表供下游做进一步分析 user_device_stats.to_sql(weekly_user_device_stats, engine, if_existsappend, indexFalse) # 自动化用 Airflow 或 Cron 每周一凌晨 2 点运行此脚本实操心得交付的不是“一张表”而是“一套契约”。我会在数据库里为每张结果表写 COMMENT说明数据范围COMMENT ON TABLE weekly_conv_rate IS 2024年第25周数据2024-06-17 至 2024-06-23;计算逻辑COMMENT ON COLUMN weekly_conv_rate.conv_rate IS purchase_count / page_view_count分子分母均按 session_id 去重后计算;更新频率COMMENT ON TABLE weekly_conv_rate IS 每周一凌晨2点更新;这能让 BI 工程师、产品经理一眼看懂数据避免“猜指标”。5. 常见问题与避坑指南那些只有踩过才懂的细节5.1 问题一pivot_table报错DataError: No numeric types to aggregate怎么办现象你明明传了valuesrevenue却提示没有数值类型可聚合。根本原因revenue列的数据类型是object字符串而非float64或int64。常见于 CSV 导入时某些行是空字符串或N/APandas 自动推断为 object。排查与解决# 1. 查看数据类型 print(df[revenue].dtype) # 如果是 object继续 # 2. 查看前几行和唯一值找异常 print(df[revenue].head()) print(df[revenue].unique()) # 3. 安全转换用 pd.to_numeric 的 errorscoerce把异常值转为 NaN df[revenue] pd.to_numeric(df[revenue], errorscoerce) # 4. 检查 NaN 比例决定是否填充或删除 nan_ratio df[revenue].isna().mean() if nan_ratio 0.01: print(f警告revenue 列有 {nan_ratio:.2%} 的缺失值) # 通常选择填充中位数或 0根据业务决定 df[revenue].fillna(df[revenue].median(), inplaceTrue)注意永远不要用df[revenue].astype(float)它遇到N/A会直接报错。pd.to_numeric(..., errorscoerce)是唯一安全的转换方式。5.2 问题二groupby后size()和count()结果不一致哪个是对的现象df pd.DataFrame({A: [1,1,1], B: [2,2,None]}) print(df.groupby(A).size()) # 输出A 3 print(df.groupby(A).count()) # 输出A 2 B 列只计了 2 个非空值原理size()统计的是分组后的行数不管值是否为空count()统计的是每列的非空值数量。对于count()它会对groupby对象中的每一列分别计数。何时用哪个用size()统计“有多少个订单”、“有多少个用户”——关注实体数量。用count()统计“订单总金额”、“用户平均年龄”——关注某列的有效值。最佳实践明确你的业务指标定义。如果指标是“订单数”必须用size()如果指标是“平均客单价”则revenue.mean()是正确姿势revenue.count()只是中间步骤。5.3 问题三多维聚合结果内存爆满MemoryError如何优化现象对一个 1 亿行的 DataFrame 做pivot_table(index[A,B,C], columns[D,E])Python 直接崩溃。原因pivot_table会尝试构建一个巨大的稀疏矩阵。如果A,B,C,D,E的组合总数是 1000 万即使每单元格只占 8 字节也要 80MB但实际中由于索引和元数据开销可能吃掉数 GB 内存。解决方案按优先级排序降维问自己真的需要 5 个维度吗能否先groupby([A,B,C,D,E]).sum()得到一个“压缩版”长表再用pivot这能减少中间对象大小。采样开发阶段用df.sample(frac0.01)快速验证逻辑。分块处理对index维度进行分块逐块pivot后concat# 按 A 列分块 chunks [] for a_val, a_group in df.groupby(A): chunk_pivot a_group.pivot_table( index[B,C], columns[D,E], valuesrevenue, aggfuncsum ) chunk_pivot.index pd.MultiIndex.from_tuples( [(a_val, *idx) for idx in chunk_pivot.index], names[A,B,C] ) chunks.append(chunk_pivot) final_result pd.concat(chunks)换引擎终极方案用 Dask 或 Polars。Dask 的pivot_table是惰性计算能自动分块并行import dask.dataframe as dd ddf dd.from_pandas(df, npartitions8) result ddf.pivot_table( index[A,B,C], columns[D,E], valuesrevenue, aggfuncsum ).compute() # compute() 才真正执行5.4 问题四SQL 中CUBE结果太多只想看特定组合如何过滤现象SELECT ... FROM t GROUP BY CUBE(a,b,c)生成 8 行结果但你只关心(a,b)和(a)这两组。解决方案用GROUPING()函数过滤。GROUPING(a)返回 1 表示该维度未参与分组即CUBE生成的“ALL”行。SELECT CASE WHEN GROUPING(a)0 THEN a ELSE ALL_A END AS a_label, CASE WHEN GROUPING(b)0 THEN b ELSE ALL_B END AS b_label, SUM(revenue) AS total FROM t GROUP BY CUBE(a,b) HAVING GROUPING(a) 0 AND GROUPING(b) IN (0,1); -- 解释GROUPING(a)0 确保 a 总是出现GROUPING(b) IN (0,1) 允许 b 出现或不出现 -- 这样就只保留了 (a,b) 和 (a) 两组排除了 (b) 和 ()。5.5 问题五时间维度聚合时to_date()和date_trunc()的区别与选型场景想按“周”聚合但发现GROUP BY EXTRACT(YEAR FROM event_time), EXTRACT(WEEK FROM event_time)会导致跨年周如 2023-12-31 是 2024 年第 1 周计算错误。正确做法用date_trunc(week, event_time)PostgreSQL, ClickHouse或toStartOfWeek(event_time)ClickHouse。-- 错误EXTRACT(WEEK) 是 ISO 周但 YEAR 是