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表包含sku_id主键、category、sub_category、brand、is_premium。注意sub_category是category的子级而不是并列字段。这避免了“手机”和“iPhone”被当成同一层级的平行选项。事实表Fact Table它是立方体的“点阵”。它本身不存储任何描述性文本只存储数字度量Measures和指向维度表的外键Foreign Keys。例如fact_sales表包含date_idFK to dim_time、region_idFK to dim_geo、sku_idFK to dim_product、revenue、cost、quantity、order_count。这里revenue是可加性度量Additivecost是半可加性Semi-additive可以按时间加但不能按区域加order_count是不可加性Non-additive只能计数不能求和。这个区分直接决定了你在不同维度上做聚合时该用SUM()还是COUNT(DISTINCT)。层次映射Hierarchy Mapping这是最容易被忽略却最影响后期灵活性的部分。它定义了“哪些维度可以一起出现”。例如time_geo_hierarchy: 允许year region、quarter province、month city的组合。product_geo_hierarchy: 允许category region、sub_category province但禁止brand city因为品牌和城市没有直接业务关联。这个映射不是代码而是一份 JSON 配置文件它会在数据操作层被读取用于校验用户输入的聚合请求是否合法。比如当 BI 工具传来一个请求“请给我brand和city的销售额”系统会先查product_geo_hierarchy发现没有这条路径就直接报错而不是返回一个毫无业务意义的、满是 NULL 的结果。提示维度表的主键如date_id,region_id必须是整数型且全局唯一。我见过太多项目用YYYY-MM-DD字符串做date_id结果在 JOIN 时性能暴跌。PyArrow 对整数 JOIN 的优化远超字符串这是底层引擎决定的硬性要求。2.3 为什么放弃传统星型模型我的轻量级替代方案传统数据仓库会严格遵循星型模型Star Schema用物理表来实现上述结构。但在快速迭代的业务分析场景中建一张dim_time表光维护节假日、闰年、财年切换就够喝一壶的。我的经验是用代码定义维度用内存构建立方体比用 DDL 建表更敏捷、更可控。具体做法是不创建物理的dim_time表而是用 Python 写一个TimeDimension类class TimeDimension: def __init__(self, start_date: str, end_date: str): self.dates pd.date_range(startstart_date, endend_date, freqD) self.df pd.DataFrame({ date_id: range(1, len(self.dates) 1), date: self.dates, year: self.dates.year, quarter: self.dates.quarter, month: self.dates.month, day: self.dates.day, is_weekend: self.dates.weekday 5, is_holiday: self._mark_holidays(self.dates) # 自定义函数 }) def get_hierarchy(self, level: str) - pd.Series: 根据层级名称返回对应的 Series用于后续 JOIN if level year: return self.df.set_index(date_id)[year] elif level quarter: return self.df.set_index(date_id)[[year, quarter]].apply( lambda x: f{x[year]}Q{x[quarter]}, axis1 ) # ... 其他层级这个类在服务启动时初始化一次所有维度信息都在内存里。当需要quarter维度时调用get_hierarchy(quarter)就能得到一个索引为date_id、值为2023Q1的 Series直接用于pd.merge。好处是维度逻辑完全由 Python 控制修改节假日规则只需改一行代码无需 DBA 审批、无需迁移脚本、无需担心锁表。3. 核心数据操作详解在立方体上“拧螺丝”的七种手法3.1 手法一维度扩展Dimension Expansion——给立方体“加轴”业务方突然说“老板要看销售额按‘客户生命周期阶段’新客/成长期/成熟期/流失预警来分。” 这是一个全新的维度它不存在于原始订单表中也不在任何现有维度表里。传统做法是回溯 ETL 流程加一个字段跑全量重算。而多维聚合的操作哲学是维度是可插拔的计算是可叠加的。我的操作步骤如下准备维度数据先用用户表和订单表计算出每个customer_id当前的生命周期阶段生成一个临时的dim_customer_lifecycleDataFrame结构为[customer_id, lifecycle_stage]。事实表打标Tagging将dim_customer_lifecycle通过customer_idJOIN 到fact_sales上得到fact_sales_enhanced它现在多了lifecycle_stage字段。构建新维度映射在product_geo_hierarchy配置中新增一条规则lifecycle_stage可以与category、region组合。执行“软扩展”不改变fact_sales的物理结构而是在聚合函数中动态注入这个新维度。核心代码如下def aggregate_with_dimension( fact_df: pd.DataFrame, dimensions: List[str], measures: Dict[str, str], # {revenue: sum, order_count: count} extra_dim_map: Optional[Dict[str, pd.Series]] None ) - pd.DataFrame: dimensions: 如 [region, quarter, lifecycle_stage] extra_dim_map: {lifecycle_stage: series_from_dim_customer_lifecycle} df fact_df.copy() # 如果有额外维度先进行映射 if extra_dim_map: for dim_name, dim_series in extra_dim_map.items(): if dim_name not in df.columns: # 将 dim_series 的 index 作为 fact_df 的 join key # 假设 fact_df 有一个 customer_id 列而 dim_series 的 index 就是 customer_id df df.merge( dim_series.rename(dim_name), left_oncustomer_id, right_indexTrue, howleft ) # 标准 GROUP BY result df.groupby(dimensions).agg(measures).reset_index() return result # 使用 result aggregate_with_dimension( fact_dffact_sales, dimensions[region, quarter, lifecycle_stage], measures{revenue: sum, order_count: count}, extra_dim_map{lifecycle_stage: dim_customer_lifecycle[lifecycle_stage]} )这个extra_dim_map参数就是给立方体“加轴”的接口。它不修改底层数据只在计算时动态注入。下次如果要加“客户行业”维度只需传入一个新的industry_series逻辑完全复用。注意extra_dim_map中的 Series其index必须与fact_df中用于 JOIN 的列名一致这里是customer_id。这是保证 JOIN 正确性的铁律。我踩过的坑是把customer_id写成cust_id结果所有新维度值全是 NaN排查了两小时才发现是拼写错误。3.2 手法二层级钻取Drill-Down与上卷Roll-Up——在坐标轴上“缩放”“华东地区 Q1 总营收是 500 万那这 500 万具体是哪些城市贡献的” 这就是典型的钻取Drill-Down从region层级下钻到city层级。“把所有城市的销售额加起来就是华东的总额”这就是上卷Roll-Up。难点在于钻取和上卷必须是可逆的、无损的。如果city层级的总和不等于region层级的值说明数据有脏、有重复、或者层级定义错了。我的实操方案是永远只在一个“最细粒度”的事实表上做聚合所有层级操作都通过维度表的映射来完成。继续用dim_geo举例。它的结构是region_idcountryprovincecity1CNSHSH2CNSHSZ3CNJSNJfact_sales表里只有region_id没有city。那么如何实现“按城市看”答案是用dim_geo表自身做“自连接”Self-Join。# 获取城市层级的销售数据 city_sales fact_sales.merge( dim_geo[[region_id, city]], onregion_id, howleft ).groupby(city).agg({revenue: sum}).reset_index() # 获取省份层级的销售数据上卷 province_sales city_sales.merge( dim_geo[[city, province]].drop_duplicates(), oncity, howleft ).groupby(province).agg({revenue: sum}).reset_index()关键点在于province_sales的结果必须和fact_sales.merge(dim_geo[[region_id, province]], onregion_id).groupby(province).agg(...)的结果完全一致。如果不一致说明dim_geo表里存在一个城市对应多个省份的脏数据必须立刻清洗。我把这个一致性校验写成了一个自动化测试在每次部署前运行确保立方体的“坐标轴”是精准的。3.3 手法三度量派生Measure Derivation——在点阵上“造新点”多维聚合里最常被低估的是度量本身的复杂性。revenue和cost是原始度量但业务真正关心的往往是profit_margin (revenue - cost) / revenue。这个计算绝不能在GROUP BY之后做否则会得到错误结果。错误示范常见陷阱# ❌ 错误这是在聚合后的结果上做除法 df_grouped fact_sales.groupby([region, quarter]).agg({ revenue: sum, cost: sum }) df_grouped[margin] (df_grouped[revenue] - df_grouped[cost]) / df_grouped[revenue]这个margin是“区域-季度”粒度的毛利率但它掩盖了内部的巨大差异。比如华东 Q1 有 1000 个订单其中 999 个是低毛利的走量款1 个是高毛利的旗舰款。SUM(revenue)和SUM(cost)相除得到的是一个加权平均但这个权重是销售额不是订单数。而业务方想要的可能是“平均单笔订单的毛利率”这就完全不同了。正确做法是在最细粒度上计算再按需聚合。# ✅ 正确先算每行的 margin再聚合 fact_sales[margin_per_order] (fact_sales[revenue] - fact_sales[cost]) / fact_sales[revenue] # 按区域-季度看“平均单笔订单毛利率” result fact_sales.groupby([region, quarter]).agg({ margin_per_order: mean, # 这才是平均 revenue: sum, order_count: count }).reset_index() # 按区域-季度看“销售额加权毛利率” result[weighted_margin] ( (result[revenue] - result[cost]) / result[revenue] )我管这叫“度量派生的黄金法则”所有衍生度量必须在事实表的原子行Atomic Row上定义然后根据业务需求选择合适的聚合函数mean, sum, count, max, min来汇总。mean和sum在这里不是随便选的它们代表了完全不同的业务语义。3.4 手法四空值填充Null Filling——让立方体“不缺胳膊少腿”回到那个经典问题如果某区域在某季度没有销售结果里就不显示这一行。这在技术上叫“稀疏立方体”Sparse Cube在业务上叫“数据黑洞”。我的解决方案是在聚合前主动构造一个“完整网格”Full Grid再用reindex去填充。# 构造所有可能的 region * quarter 组合 all_regions dim_geo[region_id].unique() all_quarters dim_time[quarter].unique() full_grid pd.MultiIndex.from_product( [all_regions, all_quarters], names[region_id, quarter] ) # 计算聚合结果 aggregated fact_sales.groupby([region_id, quarter]).agg({ revenue: sum, order_count: count }) # 用 full_grid 重索引缺失值填 0 complete_result aggregated.reindex(full_grid, fill_value0).reset_index() # 关键一步把 region_id 映射回 region_name complete_result complete_result.merge( dim_geo[[region_id, region_name]].drop_duplicates(), onregion_id, howleft )这个reindex操作就是让立方体“长出”所有理论上应该存在的点。fill_value0是最安全的默认值表示“有定义值为零”。如果你填np.nan后续的SUM或COUNT函数会把它当作缺失导致上卷时总数变小。实操心得full_grid的构造必须基于维度表而不是基于事实表。我曾经用fact_sales[region_id].unique()来构造all_regions结果漏掉了那些“注册了但还没下单”的新区域导致管理报表里看不到这些潜力区域。后来改成dim_geo[region_id].unique()问题迎刃而解。3.5 手法五跨源融合Cross-Source Fusion——把多个立方体“焊接”在一起一个公司不可能只有一个数据源。订单来自 CRM用户行为来自埋点日志库存来自 ERP。它们的主键、时间格式、区域编码往往千差万别。传统 ETL 会把这些源“清洗-转换-加载”到一个统一的事实表里。我的做法是保持各源独立只在聚合层做“语义对齐”Semantic Alignment。核心是建立一个“主键映射字典”Key Mapping Dictionary。例如CRM 里的region_code是SH,BJ而 ERP 里的warehouse_id是WH-SH-001,WH-BJ-002。我创建一个mapping_dictkey_mapping { crm: { region: {SH: shanghai, BJ: beijing}, product: {SKU-001: iphone_14} }, erp: { region: {WH-SH-001: shanghai, WH-BJ-002: beijing}, product: {IP14-PRO: iphone_14} } }然后在聚合函数里根据数据源类型动态应用映射def fuse_sources(sources: List[Tuple[str, pd.DataFrame]]) - pd.DataFrame: sources: [(crm, crm_df), (erp, erp_df)] fused_list [] for source_name, df in sources: # 应用映射 if source_name in key_mapping: for col, mapping in key_mapping[source_name].items(): if col in df.columns: df[col] df[col].map(mapping).fillna(df[col]) # 统一列名 df df.rename(columns{ region_code: region_id, warehouse_id: region_id, sku: sku_id, product_id: sku_id }) fused_list.append(df) return pd.concat(fused_list, ignore_indexTrue) # 使用 fused_fact fuse_sources([(crm, crm_sales), (erp, erp_inventory)])这个方案的好处是任何一个数据源的结构变了我只需要更新key_mapping里的对应条目而不用动整个 ETL 流水线。它把“数据治理”的复杂性收敛到了一个小小的字典里。3.6 手法六动态过滤Dynamic Filtering——给立方体装上“可调焦镜头”用户在 BI 工具里点选“只看 iPhone”这个动作本质是对立方体的一次动态切片Slice。难点在于这个过滤条件必须能穿透所有维度层级。比如用户选了category Smartphone那么sub_category里的iPhone、Samsung Galaxy都应该被包含但如果用户直接选了sub_category iPhone那category就应该被自动锁定为Smartphone不能让用户再选Laptop。我的实现是用一个递归函数根据用户选择的“叶子节点”反向推导出所有必须满足的“父节点约束”。def build_filter_conditions( selected_nodes: Dict[str, List[str]], hierarchy_config: Dict[str, List[str]] ) - Dict[str, List[str]]: selected_nodes: {sub_category: [iPhone]} hierarchy_config: {sub_category: [category], category: [department]} all_conditions selected_nodes.copy() # 递归向上找父级 def _propagate_up(node: str, values: List[str]): if node in hierarchy_config: parent hierarchy_config[node][0] # 假设每个节点只有一个直接父级 if parent not in all_conditions: # 从 dim_product 表里找出所有 category 是 iPhone 的父级 parent_values dim_product[ dim_product[sub_category].isin(values) ][category].unique().tolist() all_conditions[parent] parent_values _propagate_up(parent, parent_values) for node, values in selected_nodes.items(): _propagate_up(node, values) return all_conditions # 使用 user_filter {sub_category: [iPhone]} final_filter build_filter_conditions(user_filter, hierarchy_config) # final_filter {sub_category: [iPhone], category: [Smartphone]}这个final_filter就是最终传给query()方法的条件。它保证了用户的选择在整个维度树上是自洽的、无矛盾的。3.7 手法七增量更新Incremental Update——让立方体“呼吸”而非“窒息”一个每天增长百万行的订单表如果每次聚合都全量扫描系统很快就会崩溃。多维聚合的生命力在于它必须支持增量。我的增量策略是只计算“变化的部分”然后用concatdrop_duplicates合并到历史结果中。假设昨天的聚合结果是yesterday_cube.parquet今天新增了 10000 行订单。我不重算全部而是识别变化范围从新增订单里提取所有涉及的region_id和date_id。局部重算只对这些region_id和date_id的组合重新执行聚合。合并更新把局部重算的结果和昨天的yesterday_cube合并并用(region_id, date_id)作为去重键。# 1. 识别变化范围 new_orders load_new_orders() # 今天的新增 changed_regions new_orders[region_id].unique() changed_dates new_orders[date_id].unique() # 2. 局部重算 local_cube new_orders.groupby([region_id, date_id]).agg({ revenue: sum, order_count: count }).reset_index() # 3. 加载昨日立方体 yesterday_cube pd.read_parquet(yesterday_cube.parquet) # 4. 合并用 concat然后按 key 去重保留最新的即 local_cube 的行 combined pd.concat([yesterday_cube, local_cube], ignore_indexTrue) # 去重先按 key 排序再 drop_duplicates(keeplast) combined combined.sort_values([region_id, date_id]) incremental_cube combined.drop_duplicates( subset[region_id, date_id], keeplast )这个方案的关键在于drop_duplicates(keeplast)。因为local_cube是后concat进去的所以它会覆盖yesterday_cube中同key的旧值。这比UPDATE语句更轻量也更适合 Parquet 这种列式存储。4. 实操全流程从一张 CSV 到一个可交互的多维立方体4.1 环境准备与工具链选型我用的不是 Hadoop 或 ClickHouse而是一套极简、高效、全 Python 的本地开发栈它足以支撑日均千万级数据的交互式分析Python 3.10基础环境。Pandas 2.0核心数据处理利用其pyarrowbackend 获得极致性能。PyArrow 12.0作为 Pandas 的底层引擎它让字符串操作快 3 倍JOIN 快 5 倍。安装时务必指定pip install pandas pyarrow --upgrade。Polars可选对于超大数据集1 亿行我会用 Polars 替代 Pandas它的 LazyFrame 模式能自动优化执行计划。Streamlit前端展示50 行代码就能做出一个带下拉菜单、时间滑块、图表联动的 BI 界面。Parquet 文件所有中间结果、维度表、事实表都存为.parquet格式。它比 CSV 小 80%读取快 10 倍且原生支持列裁剪Column Pruning。注意不要用pandas.read_csv()直接读大 CSV。我的标准流程是先用pyarrow.parquet.write_table()把原始 CSV 转成 Parquet以后所有操作都基于 Parquet。转换脚本只需 3 行import pyarrow as pa import pyarrow.parquet as pq table pa.csv.read_csv(raw_data.csv) pq.write_table(table, raw_data.parquet)4.2 第一步加载与清洗原始数据假设我们有一张orders.csv内容如下order_idcustomer_idskuregion_codeorder_daterevenuecostORD-001CUST-1001SKU-001SH2023-01-155999.004200.00ORD-002CUST-1002SKU-002BJ2023-01-161299.00850.00清洗的核心目标是把所有描述性字段标准化为维度 ID。import pandas as pd import pyarrow as pa # 1. 用 PyArrow 高效读取 orders_df pd.read_parquet(orders.parquet, enginepyarrow) # 2. 标准化 region_code - region_id # 先构建一个 region_code 到 region_id 的映射 region_mapping { SH: 1, BJ: 2, GZ: 3, SZ: 4 } orders_df[region_id] orders_df[region_code].map(region_mapping) # 3. 解析 order_date - date_id # 使用前面定义的 TimeDimension 类 time_dim TimeDimension(2023-01-01, 2023-12-31) # time_dim.df 里有 date 和 date_id用 merge 做映射 orders_df[order_date] pd.to_datetime(orders_df[order_date]) orders_df orders_df.merge( time_dim.df[[date, date_id]].rename(columns{date: order_date}), onorder_date, howleft ) # 4. 清洗 sku - sku_id这里简化实际会查 dim_product 表 sku_to_id {SKU-001: 101, SKU-002: 102} orders_df[sku_id] orders_df[sku].map(sku_to_id) # 5. 最终只保留事实表需要的列 fact_sales orders_df[[ order_id, customer_id, sku_id, region_id, date_id, revenue, cost, quantity ]].copy() # 6. 存为事实表 fact_sales.to_parquet(fact_sales.parquet, enginepyarrow)这一步完成后fact_sales.parquet就是一个纯粹的、符合星型模型规范的事实表所有字段都是整数 ID 或数字度量没有任何字符串描述。4.3 第二步构建维度立方体The Cube现在我们有了fact_sales以及dim_time、dim_geo、dim_product这些维度对象。下一步是把它们“组装”成一个可查询的立方体。我定义了一个DataCube类它封装了所有核心操作class DataCube: def __init__(self, fact_df: pd.DataFrame): self.fact fact_df self.dimensions {} self.hierarchy_config {} def add_dimension(self, name: str, dim_df: pd.DataFrame, key_col: str): 添加一个维度表 self.dimensions[name] { df: dim_df, key_col: key_col } def set_hierarchy(self, config: Dict[str, List[str]]): 设置维度层级 self.hierarchy_config config def query(self, dimensions: List[str], measures: Dict[str, str], filters: Optional[Dict[str, List[str]]] None) - pd.DataFrame: 执行多维查询 dimensions: [region, quarter] measures: {revenue: sum} filters: {region: [shanghai]} df self.fact.copy() # 应用过滤 if filters: for dim_name, values in filters.items(): if dim_name in self.dimensions: dim_df self.dimensions[dim_name][df] key_col self.dimensions[dim_name][key_col] # 找出 dim_df 中 value 对应的所有 key valid_keys dim_df[dim_df[dim_name].isin(values)][key_col].unique() df df[df[key_col].isin(valid_keys)] # JOIN 所有需要的维度 for dim_name in dimensions: if dim_name in self.dimensions: dim_df self.dimensions[dim_name][df] key_col self.dimensions[dim_name][key_col] # 假设事实表里有同名的列如 region_id df df.merge(dim_df, left_onkey_col, right_onkey_col, howleft) # 执行 GROUP BY result df.groupby(dimensions).agg(measures).reset_index() return result # 使用 cube DataCube(fact_sales) cube.add_dimension(region, dim_geo, region_id) cube.add_dimension(time, dim_time.df, date_id) cube.add_dimension(product, dim_product, sku_id) cube.set_hierarchy({ quarter: [year], month: [quarter], city: [province], sub_category: [category] }) # 查询华东各城市 Q1 销售额 result cube.query( dimensions[city, quarter], measures{revenue: sum}, filters{region: [shanghai]} )这个DataCube类就是我们整个多维聚合系统的“心脏”。它把所有复杂的 JOIN、FILTER、AGGREGATE 逻辑封装成一个干净的query()接口。前端 Streamlit 应用只需要调用这个接口传入用户选择的参数就能拿到结果。4.4 第三步前端交互与可视化Streamlit 示例最后一步让业务方能自己玩转这个立方体。Streamlit 的代码简洁得惊人import streamlit as st import pandas as pd st.title(Sales Cube Explorer) # 加载立方体 cube load_cube() # 你的加载逻辑 # 创建交互控件 st.sidebar.header(Filters) selected_region st.sidebar.multiselect( Select Region, options[shanghai, beijing, guangzhou, shenzhen], default[shanghai] ) selected_quarter st.sidebar.selectbox