
1. 项目概述当数据不再是一张“平铺直叙”的表格你有没有遇到过这样的场景销售部门要按季度、按区域、按产品大类看毛利同时还要对比去年同期财务团队需要把成本拆解到“部门-项目-费用类型-发生月份”四个维度再筛选出超预算的组合甚至一个简单的用户行为分析都要交叉统计“新老用户 × 设备类型 × 页面路径 × 时间段”的点击热力图。这时候Excel 的透视表点到第三层就开始卡顿SQL 里写个 GROUP BY 带上四个字段结果一跑就是五分钟还经常漏掉某个维度的空值组合——这根本不是数据量的问题而是你还在用“二维思维”处理多维现实。Multi-Dimensional Aggregation多维聚合说白了就是把数据当成一个有长、宽、高、甚至时间轴的立方体来切片、切块、钻取和旋转。它不是简单地“分组求和”而是构建一套可动态导航的数据骨架。而Data Manipulation in Multi-Dimensional Aggregation正是这个骨架上最核心的“关节活动”——它决定了你能不能在不重建模型的前提下自由地增删维度、调整层级、计算衍生指标、合并异构来源甚至让不同业务线的聚合口径在同一个底座上对齐。这不是数据库工程师的专利而是今天每个要从数据中拿结论的产品经理、运营分析师、BI 开发者都绕不开的基本功。本文不讲 OLAP 理论只聊我在三个真实项目里如何用 Python Pandas PyArrow 手动“拧紧”这些关节把一张扁平的订单表变成能支撑 27 种业务视图的活体数据立方体。2. 多维聚合的本质设计为什么不能直接 GROUP BY 四个字段2.1 从“表格思维”到“立方体思维”的认知跃迁很多人第一次接触多维聚合下意识就去写 SQLSELECT region, product_category, quarter, SUM(revenue) AS total_revenue, AVG(profit_margin) AS avg_margin FROM sales GROUP BY region, product_category, quarter;这看起来很完美但问题藏在“完美”背后。我们来拆解一下这个查询实际构建的是什么它生成了一个3 维空间中的点集region 是 X 轴product_category 是 Y 轴quarter 是 Z 轴。每个 (X,Y,Z) 坐标上挂载着两个度量值total_revenue 和 avg_margin。但它丢失了所有更高维或更低维的视角。比如你想知道“华东地区所有产品的全年总营收”就得重新写一个GROUP BY region的查询想知道“Q1 所有区域的平均毛利率”又得写GROUP BY quarter。每一次切换都是一次全表扫描计算逻辑无法复用。更致命的是它无法表达“空组合”。如果某区域在某季度没有销售记录这个 (region, quarter) 组合在结果里就彻底消失。但在管理报表中“华东 Q1 销售额为 0”和“华东 Q1 数据缺失”是完全不同的业务含义——前者是经营结果后者是数据采集故障。真正的多维聚合目标是构建一个“预计算按需计算”混合的立方体Cube。它的核心设计原则不是“一次算完所有组合”而是“定义好所有可能的组合结构并让计算路径清晰、可追溯、可复用”。2.2 我的三维设计骨架维度表、事实表与层次映射在落地之前我强制自己画一张草图明确三个不可妥协的组件维度表Dimension Tables它们是立方体的“坐标轴”。每个维度必须是有明确层级结构Hierarchy和唯一自然键Natural Key的。例如dim_time表包含date_id主键、year、quarter、month、week_of_year、is_holiday。关键点quarter不是独立字段而是year的子级month是quarter的子级。这种父子关系决定了钻取Drill-down和上卷Roll-up的路径。dim_geo表包含region_id主键、country、province、city、is_metropolitan。这里province是country的子级city是province的子级。一个城市必然属于一个省一个省必然属于一个国家——这种确定性是聚合结果可解释性的基石。dim_product表包含product_id主键、category、sub_category、brand、is_new_launch。注意sub_category是category的子级而不是并列字段。这避免了“手机”和“iPhone”被当成同一层级的平行选项。事实表Fact Table它是立方体的“内容体”。它本身不存储任何描述性文本只存数字度量Measures和指向维度表的外键Foreign Keys。例如fact_sales表date_id关联 dim_timeregion_id关联 dim_geoproduct_id关联 dim_productrevenue原始销售额cost原始成本quantity销售数量order_count订单数关键设计所有外键都必须是非空NOT NULL且必须在对应的维度表中存在。这是保证聚合结果“不漏不重”的物理基础。我见过太多项目因为region_id允许为空导致“未知区域”的销售额被错误地计入所有其他区域的汇总中。层次映射Hierarchy Mapping这是最容易被忽略却最影响后期灵活性的部分。它定义了“哪些维度可以一起出现”以及“它们的默认聚合顺序”。我用一个 YAML 文件来管理# cube_hierarchy.yaml sales_cube: dimensions: - name: time hierarchy: - level: year key: year - level: quarter key: quarter parent: year - level: month key: month parent: quarter - name: geo hierarchy: - level: country key: country - level: province key: province parent: country - level: city key: city parent: province - name: product hierarchy: - level: category key: category - level: sub_category key: sub_category parent: category measures: - name: revenue aggregation: sum format: ¥#,##0.00 - name: profit_margin aggregation: weighted_avg weight_field: revenue这个文件不是代码而是业务契约。它告诉所有后续的开发者“当你想按‘年省’看销售时系统会自动从dim_time取year从dim_geo取province然后在fact_sales上做 JOIN 和 GROUP BY”。它把模糊的“按年按省汇总”需求翻译成了精确的、可执行的、可审计的技术指令。2.3 为什么放弃纯 SQL OLAP我的性能与可维护性权衡市面上有成熟的 OLAP 引擎如 ClickHouse、Doris、StarRocks它们能原生支持多维聚合。但我坚持在数据准备阶段ETL/ELT就用 Python 构建这套骨架原因有三第一调试成本。OLAP 引擎的查询计划极其复杂。当一个“按年份、按产品大类、按渠道”的聚合结果比预期少了 5%你是去查 SQL 逻辑、查物化视图刷新状态、查引擎的采样精度还是查原始数据里channel字段的空值处理方式在 Python 里你可以用df.groupby([year, category, channel]).agg(...)一行代码跑通再用df[df[channel].isna()]直接看到问题数据。这种“所见即所得”的调试流是任何黑盒引擎都无法替代的。第二口径一致性。一个公司里市场部的“新用户”定义是“首次访问 APP”而销售部的“新用户”定义是“首次下单”。如果把这两个口径都塞进同一个 OLAP 模型里前端 BI 工具的下拉菜单会变成一场灾难。而在 Python 中我可以为市场部定义一个get_marketing_new_user_flag()函数为销售部定义get_sales_new_user_flag()函数它们都作用于同一份清洗后的fact_user_behavior表。函数即口径版本即审计线索。第三增量更新友好。OLAP 引擎的物化视图更新往往是全量刷新或复杂的增量逻辑。而 Python 的 DataFrame 操作天然支持pd.concat([old_df, new_df])和df.drop_duplicates(subset[id], keeplast)。上周我负责的一个实时销售看板上游每分钟推送 100 条新订单我用pyarrow加载后只对新增的date_id和region_id组合做局部聚合再merge进主立方体整个过程耗时稳定在 800ms 内。这在传统 OLAP 里需要精心设计分区和物化策略才能达到。所以我的设计哲学是用 Python 做“立方体的铸造”用 OLAP 做“立方体的高速缓存”。铸造过程必须透明、可控、可测试缓存只是加速手段不是逻辑载体。3. 核心数据操作详解在立方体骨架上拧紧每一个关节3.1 关节一维度的动态增删与层级升降Dimension Manipulation多维聚合的生命力在于它能随业务变化而“生长”。上周公司新开了东南亚市场要求报表里立刻增加“国家”维度并且要能下钻到“城市”。如果所有聚合逻辑都硬编码在 SQL 里这就意味着要改 17 个报表的 SQL、3 个 ETL 脚本、2 个 API 接口。而在我这套设计里它只是一个“添加维度表字段”的动作。实操步骤扩展维度表在dim_geo表中新增country_code和country_name字段。确保country_code是country的自然键例如CN,SG,TH并且country_name是其友好显示名。更新层次映射修改cube_hierarchy.yaml在geo维度的hierarchy列表最上方插入country层级- name: geo hierarchy: - level: country # 新增作为最高层级 key: country_name - level: country_code # 新增作为技术键 key: country_code parent: country - level: province key: province parent: country_code - level: city key: city parent: province重构事实表关联在加载fact_sales时不再只关联region_id而是通过dim_geo表的country_code字段进行 JOIN# 加载维度表使用 PyArrow 提升性能 dim_geo pa.dataset.dataset(data/dim_geo.parquet, formatparquet).to_table().to_pandas() # 加载事实表 fact_sales pa.dataset.dataset(data/fact_sales.parquet, formatparquet).to_table().to_pandas() # 关键用 country_code 关联而非 region_id sales_with_geo fact_sales.merge( dim_geo[[country_code, country_name, province, city]], left_onregion_id, right_oncountry_code, # 注意这里 region_id 实际存储的就是 country_code howleft )提示这里有个重要技巧。region_id字段在事实表里其实存储的是country_code。这是为了保持事实表的轻量化。维度表的country_code是主键region_id是外键它们的值完全一致。这样当未来要增加“大区”如亚太区、欧洲区这个更高层级时只需在dim_geo表里增加region_group字段并在hierarchy里把它放在country上面所有下游聚合会自动继承这个新层级无需改动任何事实表或聚合代码。验证新维度写一个最小化的聚合脚本快速验证# 按新维度聚合 country_summary sales_with_geo.groupby([country_name, year, quarter]).agg({ revenue: sum, profit_margin: lambda x: np.average(x, weightssales_with_geo.loc[x.index, revenue]) }).round(2).reset_index() print(country_summary.head()) # 输出应包含 CN, SG, TH 的数据并且每个国家都有完整的年季组合为什么这个流程如此稳健因为所有“变化”都被约束在了维度表和 YAML 配置里。事实表和聚合逻辑是“无状态”的它只认country_code这个键。只要键存在、映射正确新的维度就像插上电源的电器一样立刻开始工作。3.2 关节二度量的灵活计算与上下文感知Measure Calculation度量Measure不是冷冰冰的 SUM 或 AVG。在多维语境下同一个数字在不同维度组合下其计算逻辑可能完全不同。例如“平均毛利率”在(country, year)维度上它是所有该国全年订单的毛利率加权平均权重为订单金额。在(product_category, quarter)维度上它应该是该品类该季度所有 SKU 的毛利率算术平均因为我们要看品类健康度而非单个爆款。在(city, month)维度上它可能根本无意义应该返回NaN并标记为“不适用”。这就是上下文感知计算Context-Aware Calculation。我拒绝用一个万能的avg_margin字段塞进事实表而是用一个“计算引擎”在聚合时动态生成。我的实现方案一个基于规则的计算注册表from typing import Callable, Dict, Any import numpy as np class MeasureCalculator: def __init__(self): self._registry: Dict[str, Dict[str, Callable]] {} def register(self, measure_name: str, context: str, func: Callable): 注册一个度量在特定上下文下的计算函数 if measure_name not in self._registry: self._registry[measure_name] {} self._registry[measure_name][context] func def calculate(self, measure_name: str, context: str, df: pd.DataFrame, **kwargs) - pd.Series: 根据上下文调用对应的计算函数 if measure_name not in self._registry: raise ValueError(fMeasure {measure_name} not registered) if context not in self._registry[measure_name]: # 如果没有为该上下文注册回退到默认计算 context default if context not in self._registry[measure_name]: raise ValueError(fNo calculation function for {measure_name} in context {context}) return self._registry[measure_name][context](df, **kwargs) # 创建全局计算器实例 calc MeasureCalculator() # 注册毛利率计算 def weighted_avg_margin(df, revenue_colrevenue, margin_colprofit_margin): 加权平均以 revenue 为权重 return np.average(df[margin_col], weightsdf[revenue_col]) def simple_avg_margin(df, margin_colprofit_margin): 简单平均 return df[margin_col].mean() def not_applicable(_df, **_kwargs): 不适用返回 NaN return np.nan # 为不同上下文注册 calc.register(profit_margin, country_year, weighted_avg_margin) calc.register(profit_margin, category_quarter, simple_avg_margin) calc.register(profit_margin, city_month, not_applicable) calc.register(profit_margin, default, weighted_avg_margin) # 默认回退在聚合时调用def aggregate_with_context( df: pd.DataFrame, group_cols: list, measures: list, calculator: MeasureCalculator ) - pd.DataFrame: 根据 group_cols 推断上下文并调用计算器 规则group_cols 长度和内容决定上下文 context_key _.join(sorted([c for c in group_cols if c in [country_name, year, quarter, category, city, month]])) # 简化上下文映射实际项目中会更复杂 context_map { country_name_year: country_year, category_quarter: category_quarter, city_month: city_month, country_name_quarter: country_year, # 同属国家时间维度复用 } context context_map.get(context_key, default) agg_dict {} for measure in measures: if measure profit_margin: # 对于 profit_margin不走常规 agg而是调用计算器 agg_dict[measure] lambda x: calculator.calculate(measure, context, x) else: # 其他度量走常规聚合 agg_dict[measure] sum if measure revenue else count return df.groupby(group_cols).agg(agg_dict).reset_index() # 使用示例 result aggregate_with_context( sales_with_geo, group_cols[country_name, year], measures[revenue, profit_margin], calculatorcalc )这个设计的价值在于业务解耦财务总监说“毛利率在国家层面必须加权”产品经理说“在品类层面要看简单平均”这两个需求互不干扰各自注册自己的函数即可。可测试性强每个weighted_avg_margin函数都可以单独写单元测试用几行模拟数据就能验证逻辑是否正确。可追溯result.attrs[calculation_context] country_year这个属性会一直伴随数据流向 BI 工具前端开发人员一眼就能看到这个数字是怎么算出来的。3.3 关节三空值组合的主动填充与业务语义注入Handling Empty Combinations这是多维聚合里最体现“资深”功力的地方。很多初学者认为GROUP BY没出来的组合就是“没有数据”直接不管。但业务语言里“没有数据”和“数据为零”是天壤之别。我的标准操作流程识别所有可能的组合Cartesian Product在聚合前先生成一个“理论上的完整组合网格”。# 基于维度表生成所有可能的 (country, year, quarter) 组合 all_countries dim_geo[country_name].drop_duplicates().tolist() all_years [2022, 2023, 2024] all_quarters [Q1, Q2, Q3, Q4] # 生成笛卡尔积 from itertools import product full_grid pd.DataFrame( list(product(all_countries, all_years, all_quarters)), columns[country_name, year, quarter] )右连接RIGHT JOIN填充空值将聚合结果与这个完整网格进行RIGHT JOIN把所有理论上存在的组合都拉进来。# 先做常规聚合 agg_result sales_with_geo.groupby([country_name, year, quarter]).agg({ revenue: sum, order_count: count }).reset_index() # 右连接确保 full_grid 中的所有行都在结果里 complete_result full_grid.merge( agg_result, on[country_name, year, quarter], howleft # 这里是 left因为我们是以 full_grid 为基准 ) # 填充空值revenue 为 0order_count 为 0但要标记来源 complete_result[revenue] complete_result[revenue].fillna(0) complete_result[order_count] complete_result[order_count].fillna(0) complete_result[data_status] calculated # 默认是计算出来的 complete_result.loc[complete_result[revenue] 0, data_status] zero_value # 关键一步找出那些在事实表里根本不存在的组合即连 0 都不是是纯粹的缺失 missing_mask complete_result[revenue].isna() | complete_result[order_count].isna() complete_result.loc[missing_mask, data_status] missing_data complete_result.loc[missing_mask, revenue] 0 # 统一设为 0但状态不同 complete_result.loc[missing_mask, order_count] 0注入业务语义标签data_status这个字段就是业务语言的翻译器。data_status业务含义前端展示建议后续行动calculated正常计算有正向数据绿色数字无需干预zero_value有数据记录但值为零黄色数字加注释“已确认为零”业务方确认是否合理missing_data该组合在源系统中无任何记录红色数字加注释“数据采集异常”触发告警通知数据工程师排查注意这个data_status字段必须作为度量的一部分和revenue一起流入 BI 工具。我见过太多项目把“缺失”和“为零”都显示成0导致管理层误判市场表现。一个红色的missing_data标签比一百行监控日志都管用。3.4 关节四跨源数据的融合与口径对齐Cross-Source Integration现实世界的数据从来不是来自一个地方。我们的销售数据来自 ERP用户行为数据来自埋点 SDK市场费用数据来自 Excel 表格。它们的“区域”定义各不相同ERP 里叫region_code埋点里叫geo_locationExcel 里直接是华东大区这样的中文字符串。我的融合策略统一映射字典Canonical Mapping Dictionary我创建一个mapping/canonical_geo.csv文件作为所有数据源的“普通话词典”source_systemsource_valuecanonical_codecanonical_nameconfidence_scoreerpCN_EASTCN_SH上海1.0erpCN_SOUTHCN_GZ广州1.0sdkshanghaiCN_SH上海0.95sdkguangzhouCN_GZ广州0.95excel华东大区CN_EAST华东大区0.8excel华南大区CN_SOUTH华南大区0.8融合流程加载并缓存映射字典canonical_geo pd.read_csv(mapping/canonical_geo.csv) # 构建一个高效的映射函数 def map_to_canonical(source_system: str, source_value: str) - tuple: 根据源系统和源值返回标准码和标准名 match canonical_geo[ (canonical_geo[source_system] source_system) (canonical_geo[source_value] source_value) ] if len(match) 0: row match.iloc[0] return row[canonical_code], row[canonical_name], row[confidence_score] else: return None, None, 0.0 # 向量化提升性能 vectorized_map np.vectorize(map_to_canonical)在各数据源加载时统一转换# 加载 ERP 销售数据 erp_sales pd.read_parquet(data/erp_sales.parquet) erp_sales[canonical_region_code], erp_sales[canonical_region_name], _ \ vectorized_map(erp, erp_sales[region_code]) # 加载 SDK 用户行为数据 sdk_events pd.read_parquet(data/sdk_events.parquet) sdk_events[canonical_region_code], sdk_events[canonical_region_name], _ \ vectorized_map(sdk, sdk_events[geo_location]) # 加载 Excel 市场费用 excel_marketing pd.read_excel(data/marketing_cost.xlsx) excel_marketing[canonical_region_code], excel_marketing[canonical_region_name], _ \ vectorized_map(excel, excel_marketing[region_desc])融合后用标准码进行聚合# 将所有数据源 union 到一起 all_data pd.concat([ erp_sales.assign(data_sourceerp, measure_typerevenue), sdk_events.assign(data_sourcesdk, measure_typeuser_event), excel_marketing.assign(data_sourceexcel, measure_typemarketing_cost) ], ignore_indexTrue) # 现在所有数据都拥有了相同的 canonical_region_code final_aggregation all_data.groupby([canonical_region_code, canonical_region_name, year, quarter]).agg({ revenue: sum, user_event: count, marketing_cost: sum }).reset_index()这个策略的核心优势是源头治理问题解决在数据接入的第一步而不是在最后的报表里用 CASE WHEN 硬凑。可审计mapping/canonical_geo.csv是一个版本化的文件每次修改都有 Git 记录谁在什么时候把shanghai映射到了CN_SH一目了然。渐进式confidence_score字段允许我们先用高置信度的映射如 ERP再逐步完善低置信度的如 Excel不影响整体流程。4. 实操全流程从一张订单 CSV 到可交互的多维立方体4.1 环境准备与工具选型为什么是 PyArrow Pandas在开始编码前我花了一整天做基准测试。对比了pandas.read_csv、dask.dataframe、polars和pyarrow在处理 1GB 订单 CSV 文件时的性能工具加载时间内存占用GROUP BY 10min优点缺点pandas.read_csv42s3.2GB18sAPI 熟悉生态丰富内存爆炸无法处理更大文件dask.dataframe58s1.1GB22s支持并行内存友好调试困难报错信息晦涩polars16s1.8GB8s速度最快内存适中Python 生态弱学习成本高pyarrow11s1.5GB6s极致性能无缝对接 pandasParquet 原生支持对复杂字符串处理稍弱最终选择PyArrow理由非常务实它不是为了炫技而是为了“稳”。pyarrow的datasetAPI 可以直接读取分区的 Parquet 文件跳过不需要的列这对于拥有 50 字段的事实表来说是性能的倍增器。而且pyarrow.Table.to_pandas()返回的就是标准的pandas.DataFrame我所有的聚合逻辑、计算函数、测试用例一行代码都不用改。我的标准环境配置# requirements.txt pandas2.0.3 pyarrow12.0.1 numpy1.24.3 pyyaml6.0.1初始化脚本init_env.pyimport pyarrow as pa import pyarrow.dataset as ds import pandas as pd import numpy as np import yaml # 设置 PyArrow 的全局选项提升性能 pa.set_cpu_count(8) # 使用 8 个 CPU 核心 pa.set_io_thread_count(4) # IO 线程数 # 配置 Pandas 显示选项 pd.options.display.max_columns 20 pd.options.display.float_format {:.2f}.format # 加载层次配置 with open(config/cube_hierarchy.yaml, r) as f: HIERARCHY_CONFIG yaml.safe_load(f)4.2 第一步清洗与标准化The Dirty Work这是整个流程里最枯燥也最关键的一步。我把它称为“数据的外科手术”。原始订单 CSV (raw_orders.csv) 的典型问题order_date字段格式混乱2023-01-01,01/01/2023,20230101, 甚至Jan 1st, 2023region字段East China,east_china,EC,华东,NULLproduct_id字段P123,p123, P123 ,PROD-123revenue字段1,234.56,$1,234.56,1234.56 USD,我的清洗函数clean_order_data.pyimport re from datetime import datetime def clean_order_date(date_str: str) - pd.Timestamp: 统一解析各种日期格式 if pd.isna(date_str): return pd.NaT date_str str(date_str).strip() # 定义多种解析模式 patterns [ (r^\d{4}-\d{2}-\d{2}$, %Y-%m-%d), # 2023-01-01 (r^\d{2}/\d{2}/\d{4}$, %m/%d/%Y), # 01/01/2023 (r^\d{4}\d{2}\d{2}$, %Y%m%d), # 20230101 (r^[A-Za-z]\s\d{1,2}(st|nd|rd|th),?\s\d{4}$, %B %d, %Y), # Jan 1st, 2023 ] for pattern, fmt in patterns: if re.match(pattern, date_str): try: return pd.to_datetime(date_str, formatfmt) except: continue # 如果都失败尝试泛化解析 try: return pd.to_datetime(date_str) except: return pd.NaT def clean_region(region_str: str) - str: 标准化 region 字符串 if pd.isna(region_str): return UNKNOWN region_str str(region_str).strip().upper() # 统一替换常见别名 region_str region_str.replace(EAST CHINA, EAST).replace(WEST CHINA, WEST) region_str region_str.replace(EASTERN, EAST).replace(WESTERN, WEST) region_str re.sub(r[^A-Z], , region_str) # 只保留字母 return region_str or UNKNOWN def clean_product_id(pid: str) - str: 标准化 product_id if pd.isna(pid): return UNKNOWN pid str(pid).strip().upper() # 移除前缀和空格 pid re.sub(r^P|PROD-|PRODUCT-, , pid) pid re.sub(r\s, , pid) return pid or UNKNOWN def clean_revenue(rev_str: str) - float: 提取纯数字 revenue if pd.isna(rev_str): return 0.0 rev_str str(rev_str) # 移除所有非数字字符除了小数点和负号 num_str re.sub(r[^\d.-], , rev_str) try: return float(num_str) except: return 0.0 # 应用清洗 raw_orders pa.dataset.dataset(data/raw_orders.csv, formatcsv).to_table().to_pandas() cleaned_orders raw_orders.copy() cleaned_orders[order_date] cleaned_orders[order_date].apply(clean_order_date) cleaned_orders[region] cleaned_orders[region].apply(clean_region) cleaned_orders[product_id] cleaned_orders[product_id].apply(clean_product_id) cleaned_orders[revenue] cleaned_orders[revenue].apply(clean_revenue) # 保存为 Parquet为后续高效处理做准备 cleaned_orders.to_parquet(data/cleaned_orders.parquet, indexFalse)实操心得清洗函数必须是幂等的Idempotent。这意味着我可以放心地对同一份数据运行clean_order_data.py十次结果都完全一样。这是自动化流水线的基石。我所有的清洗函数输入都是单个值输出也是单个值不依赖外部状态不修改全局变量。这样当我发现一个新的脏数据模式时只需要修改clean_region函数里的一个replace然后重新跑一遍整个数据集就焕然一新。4.3 第二步构建维度表与事实表Building the Skeleton清洗后的数据是“肉”维度表和事实表才是“骨”。构建dim_time# 从 cleaned_orders 中提取所有唯一的 order_date dates cleaned_orders[order_date].dropna().unique() dim_time pd.DataFrame({date: dates}) #