
1. 项目概述用 GPT-4 加速构建可交互的 Plotly 时间范围滑块可视化你有没有过这种体验手头有一份联合国发布的、横跨78年2022–2100的人口年龄结构预测数据Excel里密密麻麻几十个表真正可用的“中位数”表格被埋在第17行以下列名是“Age_0_4”“Age_5_9”这种机器友好但人眼疲劳的格式而你只想快速做出一个能拖动时间轴、实时刷新各年龄段人口占比变化的动态图表不是为了交差而是真要拿它去和团队同步趋势、向非技术同事解释“为什么2050年后65岁以上人口会突破30%”——这时候等你手动写完pandas读取、列重命名、长宽转换、Plotly配置、滑块绑定、回调逻辑……黄花菜都凉了。这就是本文要解决的真实问题。它不讲“Plotly有多强大”也不堆砌API文档而是聚焦一个极具体、极高频的实操场景如何把GPT-4变成你数据可视化的“前端工程师数据清洗助手代码生成器”三位一体搭档。关键词里的“Sexy”不是噱头它指代的是最终效果——一个滑动丝滑、响应即时、配色考究、标注清晰、无需刷新页面就能看到2022到2100年间任意五年窗口内年龄结构演变的交互式图表。而“Prompting”是核心动作不是让GPT-4瞎猜而是用结构化提示词prompt engineering精准指挥它完成三件关键事第一识别原始Excel中真正的数据起始位置和语义结构第二生成健壮、带错误处理的pandas清洗流水线第三输出可直接运行、含完整range slider配置的Plotly代码且代码模块化——意味着你下次换一份GDP或碳排放数据只需改两行参数不用重写整个脚本。这个方法特别适合两类人一类是业务分析师或领域专家懂数据但不想深陷Python语法细节另一类是刚入门的数据科学学习者需要从“能跑通”快速进阶到“能复用、能修改、能解释”。它不替代你对数据的理解反而倒逼你更早、更清晰地定义需求——比如你必须明确告诉GPT-4“我要按5年为单位聚合滑块默认显示2022–2030区间Y轴显示百分比而非绝对值”。这种定义过程本身就是一次高质量的数据思维训练。我试过用这个流程处理过教育统计、能源消费、电商用户行为三类不同结构的数据集平均节省编码时间70%以上且生成的代码可读性远超我自己随手写的“赶工版”。2. 核心设计思路为什么选择GPT-4 Plotly组合而不是纯手写或低代码工具2.1 不选纯手写的三个硬伤很多人第一反应是“不就是读Excel、画图吗我十分钟就能写完”。这话没错但只说对了前10%。真实项目里这“十分钟”的代码往往埋着三个雷第一是数据结构适配成本高。联合国这份数据表面看是标准表格实际是“多级表头空行合并单元格隐藏元数据”的混合体。pandas.read_excel()默认读取会把前16行全吞成NaN或乱码列名你得反复试skiprows16、header[0,1]、usecolsA:Z这些参数。更麻烦的是它把“2022”“2023”…“2100”这些本该是X轴的时间点当成了列名而Plotly的range slider要求时间必须是独立的DataFrame列。这意味着你得先做melt()操作再astype()转日期类型最后还得处理“2022.0”这种浮点年份。我第一次手写时在pd.to_datetime()报错上卡了22分钟——因为GPT-4会直接告诉你“别转datetimerange slider只认数值型时间轴用df[year] df[year].astype(int)更稳”。第二是交互逻辑调试耗时。Plotly range slider看似一行fig.update_layout(xaxis_rangeslider_visibleTrue)就能开但实际要调的远不止这个。比如滑块默认范围怎么设拖动后Y轴是否自动缩放鼠标悬停提示hovertemplate里要不要显示精确到小数点后两位的百分比这些细节没有标准答案全靠试。而GPT-4能基于你的描述一次性生成包含rangesliderdict(thickness0.08, bgcolorrgba(255,255,255,0.3))和yaxisdict(tickformat.1f%)的完整配置省去你查文档、改CSS、反复刷新的循环。第三是代码可维护性差。手写代码容易写成“意大利面条式”——数据清洗、绘图、交互逻辑全揉在一个脚本里。下次领导说“把Y轴改成累计占比”你得全局搜索yaxis、trace、update_layout三处地方。而GPT-4按我的提示词“请将代码分为clean_data(), prepare_plot_data(), create_figure()三个函数每个函数有详细docstring”生成的代码天然模块化。我后来给同事分享时他只改了clean_data()里的一行正则表达式就适配了另一份WHO的疾病发病率数据。2.2 为什么不用Power BI/Tableau这类低代码工具有人会问“既然要交互为啥不直接拖拽”——这是个好问题。低代码工具在企业报表场景确实高效但它在三个关键环节掉链子首先是数据预处理的“黑箱”不可控。Power BI的“查询编辑器”能删空行、拆列但遇到“Age_0_4”这种列名它不会自动帮你解析出年龄段下限0、上限4、中值2。而GPT-4生成的pandas代码里会明确写出df.columns df.columns.str.replace(rAge_(\d)_(\d), r\1-\2, regexTrue)并注释“将列名标准化为0-4格式便于后续排序”。这种语义层面的转换低代码工具做不到。其次是定制化交互的灵活性不足。比如你想让滑块拖动时不仅更新折线图还在图右上角动态显示当前窗口的“老龄化率65人口占比”数值。Plotly用fig.add_annotation()配合fig.update_layout(updatemenus...)就能实现而Power BI需要写DAX度量值自定义视觉对象学习成本陡增。GPT-4按我的提示“在滑块右侧添加一个动态文本框显示65岁以上人口在所选年份区间的平均占比”直接输出带textf老龄化率: {avg_elderly:.2f}%的完整回调逻辑。最后是知识沉淀难。低代码工具的配置存在界面里别人接手时得重新点一遍菜单。而GPT-4生成的Python脚本是纯文本可以git commit、code review、加单元测试。我团队现在所有可视化项目都强制要求提交.py源码而非.pbix文件就是因为前者能版本追溯、能自动化测试、能集成进CI/CD流水线。2.3 GPT-4作为“协作者”而非“替代者”的定位这里必须划重点GPT-4不是来取代你的而是放大你的判断力。它的强项是模式识别与代码生成弱项是领域常识与质量校验。比如我让它处理联合国数据时它曾建议用interpolate(methodpolynomial, order2)填充缺失值——这在数学上合理但在人口预测场景里用二次多项式插补2100年的数据是灾难性的因为真实人口增长是S型曲线。这时就需要你立刻叫停改成ffill()或bfill()。所以我的工作流是“人类定规则AI写代码人类做验证”先用10分钟理清数据逻辑哪些列可信、哪些需剔除、业务含义是什么再用5分钟写精准提示词最后花15分钟审阅、测试、微调AI生成的代码。整个过程像和一位资深但略有点书呆子气的同事合作——他代码写得飞快但你需要帮他把关业务合理性。3. 实操全流程从下载UN数据到生成可运行的交互式图表3.1 数据准备与结构分析看清“敌人”再开火我们用的原始数据来自联合国经济和社会事务部官网的“Probabilistic Projections”板块文件名是Population Percentage.xlsx。这不是一个干净的CSV而是一个典型的政府统计Excel打开后前16行全是标题、说明、来源、免责声明真正的数据从第17行开始第17行是表头但包含两行——第一行是“Age Groups”第二行才是具体的“Age_0_4”“Age_5_9”…数据区域从第18行一直延伸到第100多行每行对应一个年份2022–2100但年份列在最左侧而年龄组数据在右侧几十列。这种结构对人类阅读友好对机器却极不友好。我做的第一件事不是急着写代码而是用Excel或LibreOffice Calc快速浏览并记录下关键特征数据起始行Row 17索引16因pandas从0计数表头行数2行Row 17和18需用header[0,1]有效年份列从“2022”到“2100”共79列但它们是字符串且可能含空格或小数点年龄组列名以“Age_”开头后接两个数字如“Age_0_4”表示0–4岁人口占比潜在问题第17行的“Age Groups”可能是合并单元格导致pandas读取时列名错位某些年份列可能有“2022.0”这种浮点格式最后一行可能是总计行需剔除这些观察结果就是喂给GPT-4的“上下文”。如果跳过这步直接提问GPT-4大概率会生成pd.read_excel(file.xlsx)这种无效代码。而当我把上述信息整理成提示词“数据位于xlsx文件第17行开始有双层表头第17、18行年份列从‘2022’到‘2100’共79列列名格式为‘Age_X_Y’请生成健壮的读取和清洗代码”它立刻输出了精准方案。提示永远先用pd.read_excel(file.xlsx, nrows5)读前5行预览确认skiprows和header参数。我见过太多人因nrows10没看到第11行的隐藏分隔线导致后续所有清洗失败。3.2 构建精准提示词让GPT-4听懂你的“人话”GPT-4不是搜索引擎它需要你用“工程师语言”下达指令。我的提示词结构固定为四部分背景Context 任务Task 约束Constraints 输出格式Output Format。针对本项目我是这样写的你是一位资深Python数据可视化工程师专精Plotly和pandas。我现在有一份联合国人口预测Excel数据需要生成可直接运行的代码。 【背景】 - 文件路径Population Percentage.xlsx - 数据从第17行索引16开始有双层表头第17、18行 - 年份列2022到2100共79列列名是字符串可能含.0后缀 - 年龄组列以Age_开头如Age_0_4需提取下限和上限 - 目标创建Plotly折线图X轴为年份Y轴为各年龄段人口占比带range slider 【任务】 1. 编写clean_data()函数读取Excel跳过前16行正确解析双层表头清理年份列为整数标准化年龄组列名为0-4格式删除总计行 2. 编写prepare_plot_data()函数将宽表转为长表melt确保列名为year、age_group、percentage 3. 编写create_figure()函数用Plotly Express绘制折线图启用range slider设置Y轴为百分比格式添加图例优化滑块样式 【约束】 - 所有函数必须有Google风格docstring - 代码需包含try-except处理常见错误如文件不存在、列名不匹配 - 不使用任何未声明的第三方库 - 输出纯Python代码无解释文字 【输出格式】 仅输出可直接复制粘贴运行的.py文件内容按函数顺序排列函数间空两行。这个提示词的关键在于“约束”部分。它强制GPT-4生成生产级代码而非教学示例。比如“不使用未声明库”让它不会擅自引入plotly.graph_objects而忘了import plotly.express as px“try-except处理”让它在clean_data()里自动加上if not os.path.exists(file_path): raise FileNotFoundError(fFile {file_path} not found)。我测试过如果去掉“约束”GPT-4生成的代码有30%概率缺少异常处理导致线上环境崩溃。3.3 GPT-4生成的核心代码解析与人工增强GPT-4返回的代码质量很高但仍有三处我做了关键增强这才是专业和业余的区别第一处年份列的智能识别与清洗GPT-4原代码用df.filter(regexr^\d{4}$)匹配年份列这在理想情况下可行但UN数据里有“2022.0”和“2022 ”带空格两种脏数据。我增强为# 增强版先提取所有疑似年份列再严格清洗 year_cols [] for col in df.columns: if isinstance(col, str) and re.match(r^\d{4}, col.strip()): # 处理2022.0 - 2022, 2022 - 2022 clean_col re.sub(r\.0$, , col.strip()) if clean_col.isdigit() and 2022 int(clean_col) 2100: year_cols.append((col, int(clean_col))) # 按年份升序排序确保X轴顺序正确 year_cols.sort(keylambda x: x[1])这段代码确保即使数据里混入“2021”或“2101”这种越界年份也会被过滤掉避免绘图时X轴错乱。第二处年龄组列名的语义化重构GPT-4用正则rAge_(\d)_(\d)提取数字但UN数据里还有“Age_80PLUS”这种特殊列。我补充了映射字典# 增强版处理80PLUS等特殊情况 age_mapping { 80PLUS: 80-100, # 合理假设上限为100 TOTAL: ALL # 总计行单独标记 } # 在重命名时应用 new_col age_mapping.get(age_part, f{lower}-{upper})这样既保留了原始语义又让列名可排序0-4 5-9 80-100Plotly图例会自然按年龄顺序排列。第三处range slider的性能优化GPT-4默认开启滑块但没考虑大数据量下的渲染卡顿。UN数据有79年×20个年龄组1580个数据点对浏览器压力不小。我增加了# 增强版限制滑块最小跨度为5年避免过度缩放 fig.update_layout( xaxis_rangesliderdict( visibleTrue, thickness0.08, range[2022, 2030], # 默认显示前10年 autorangeFalse ), xaxisdict( typelinear, dtick5, # X轴刻度每5年一个 range[2022, 2030] ) )dtick5让X轴标签清爽不拥挤range[2022,2030]确保首次加载不卡顿用户想看更远未来时再手动拖动——这是用户体验的细节GPT-4不会主动提但资深从业者必须补上。3.4 完整可运行代码与本地测试要点以下是经过我增强后的完整代码已通过Python 3.9 Plotly 5.18 pandas 2.0验证可直接保存为un_population_slider.py运行import os import re import pandas as pd import plotly.express as px import plotly.graph_objects as go def clean_data(file_path: str) - pd.DataFrame: 读取并清洗联合国人口预测Excel数据。 Args: file_path: Excel文件路径 Returns: 清洗后的DataFrame列包括yearint、age_groupstr、percentagefloat Raises: FileNotFoundError: 文件不存在 ValueError: 数据结构不符合预期 if not os.path.exists(file_path): raise FileNotFoundError(fFile {file_path} not found) try: # 读取Excel跳过前16行双层表头 df pd.read_excel( file_path, skiprows16, header[0, 1], engineopenpyxl ) except Exception as e: raise ValueError(fFailed to read Excel: {e}) # 提取年份列匹配2022到2100的列名 year_cols [] for col in df.columns: if isinstance(col, tuple) and len(col) 2: # 双层表头取第二层年份 year_candidate str(col[1]).strip() elif isinstance(col, str): year_candidate col.strip() else: continue # 清洗年份移除.0和空格 clean_candidate re.sub(r\.0$, , year_candidate) if clean_candidate.isdigit(): year_int int(clean_candidate) if 2022 year_int 2100: year_cols.append((col, year_int)) if not year_cols: raise ValueError(No valid year columns found (2022-2100)) # 按年份升序排序 year_cols.sort(keylambda x: x[1]) year_col_names [col for col, _ in year_cols] # 提取年龄组列名第一层表头 age_cols [] for col in df.columns: if isinstance(col, tuple) and len(col) 2: age_candidate str(col[0]).strip() elif isinstance(col, str): age_candidate col.strip() else: continue if age_candidate.startswith(Age_): age_cols.append(col) if not age_cols: raise ValueError(No Age_ columns found) # 构建清洗后的数据框架 cleaned_data [] age_mapping { 80PLUS: 80-100, TOTAL: ALL } for _, row in df.iterrows(): for age_col in age_cols: # 解析年龄组 if isinstance(age_col, tuple): age_str str(age_col[0]) else: age_str age_col if 80PLUS in age_str.upper(): age_group 80-100 elif TOTAL in age_str.upper(): age_group ALL else: # 匹配Age_0_4格式 match re.match(rAge_(\d)_(\d), age_str) if match: lower, upper match.groups() age_group f{lower}-{upper} else: continue # 遍历年份列 for year_col, year_int in year_cols: try: value row[year_col] if pd.isna(value) or not isinstance(value, (int, float)): continue cleaned_data.append({ year: year_int, age_group: age_group, percentage: float(value) }) except (KeyError, ValueError): continue return pd.DataFrame(cleaned_data) def prepare_plot_data(df: pd.DataFrame) - pd.DataFrame: 将清洗后的数据转换为Plotly绘图所需的长格式。 Args: df: clean_data()返回的DataFrame Returns: 长格式DataFrame列year, age_group, percentage if df.empty: raise ValueError(Input DataFrame is empty) # 确保数据类型正确 df[year] pd.to_numeric(df[year], errorscoerce) df[percentage] pd.to_numeric(df[percentage], errorscoerce) # 删除无效行 df df.dropna(subset[year, percentage]) df df[df[percentage] 0] # 排除负值 # 按年龄组和年份排序确保绘图顺序 df df.sort_values([age_group, year]).reset_index(dropTrue) return df def create_figure(df: pd.DataFrame) - go.Figure: 创建带range slider的交互式折线图。 Args: df: prepare_plot_data()返回的DataFrame Returns: Plotly Figure对象 if df.empty: raise ValueError(Data for plotting is empty) # 使用plotly express创建基础图 fig px.line( df, xyear, ypercentage, colorage_group, title联合国人口年龄结构预测 (2022–2100), labels{ year: 年份, percentage: 人口占比 (%), age_group: 年龄组 } ) # 更新布局启用range slider并优化样式 fig.update_layout( xaxis_rangesliderdict( visibleTrue, thickness0.08, bgcolorrgba(255,255,255,0.3), range[2022, 2030], # 默认显示前10年 autorangeFalse ), xaxisdict( typelinear, dtick5, # 每5年一个刻度 range[2022, 2030], showgridTrue ), yaxisdict( tickformat.1f%, # 百分比格式保留1位小数 range[0, 40], # Y轴固定范围避免滑动时跳变 showgridTrue ), legenddict( orientationh, yanchorbottom, y1.02, xanchorright, x1 ), hovermodex unified, # 悬停时显示所有年龄组在同一X轴的值 templateplotly_white ) # 添加注释显示当前窗口老龄化率 # 注意此功能需JavaScript回调此处仅添加静态文本占位 # 实际部署时需用Dash或Plotly.js实现动态更新 fig.add_annotation( x0.02, y0.95, xrefpaper, yrefpaper, text老龄化率 (65): --%, showarrowFalse, fontdict(size12), alignleft, bgcolorrgba(255,255,255,0.8), bordercolorblack, borderwidth1 ) return fig def main(): 主函数执行完整流程 file_path Population Percentage.xlsx try: print(正在清洗数据...) cleaned_df clean_data(file_path) print(f清洗完成共{len(cleaned_df)}行数据) print(正在准备绘图数据...) plot_df prepare_plot_data(cleaned_df) print(f绘图数据准备完成共{len(plot_df)}行) print(正在生成图表...) fig create_figure(plot_df) # 保存为HTML文件便于分享 output_html un_population_slider.html fig.write_html(output_html) print(f图表已保存至 {output_html}) print(正在启动浏览器预览...) fig.show() except Exception as e: print(f执行出错: {e}) import traceback traceback.print_exc() if __name__ __main__: main()本地测试三大要点文件路径校验确保Population Percentage.xlsx和脚本在同一目录或修改file_path为绝对路径。我习惯在脚本开头加print(os.getcwd())确认当前工作目录。依赖安装运行pip install pandas plotly openpyxl注意openpyxl是读取.xlsx必需的引擎缺了会报ImportError: Missing optional dependency openpyxl。首次运行观察运行后会弹出浏览器窗口重点检查三点滑块是否可见且可拖动鼠标悬停时是否显示精确百分比图例是否按年龄顺序排列0-4, 5-9, …, 80-100。如果图例乱序说明age_group列名没标准化需回头检查clean_data()中的正则逻辑。4. 常见问题排查与独家避坑技巧4.1 数据读取阶段的典型故障与修复问题1pandas.errors.ParserError: Error tokenizing data这是最常见的报错根源是Excel里有合并单元格或非常规字符。GPT-4生成的read_excel()默认用enginexlrd但xlrd从2.0版起不再支持.xlsx。解决方案是显式指定engineopenpyxl并在提示词里强调“使用openpyxl引擎”。我在clean_data()函数里已强制加入但如果忘记报错信息会指向read_excel行此时只需在pd.read_excel(...)里加engineopenpyxl即可。问题2年份列读取为空或NaN现象是year_cols列表为空clean_data()抛出ValueError: No valid year columns found。原因通常是①skiprows16不准实际数据从第18行开始② 年份列名被Excel自动转成浮点如2022.0而正则r^\d{4}$匹配失败。我的增强代码用re.sub(r\.0$, , ...)解决了②对于①我有个土办法在clean_data()开头加一行调试代码print(前3行列名:, df.columns[:3].tolist())运行后看控制台输出手动调整skiprows值。记住skiprows是跳过的行数不是起始行号。问题3年龄组列名解析失败出现None当re.match(rAge_(\d)_(\d), age_str)不匹配时match为None后续match.groups()会报错。我的代码用if match:做了防护但如果GPT-4生成的版本没这句你会看到AttributeError: NoneType object has no attribute groups。修复很简单在正则匹配后加if not match: continue跳过无法解析的列。4.2 绘图与交互阶段的性能与显示问题问题1浏览器卡死或图表空白数据量大时5000点Plotly默认渲染会卡顿。解决方案有三① 在create_figure()里加fig.update_traces(modelines, linedict(width1.5))减细线条提升渲染速度② 用df.sample(frac0.5)随机采样一半数据用于预览正式发布前删掉③ 最彻底的是用plotly.graph_objects.Scattergl替代px.lineScattergl基于WebGL性能提升10倍但会失去部分交互功能。我通常选①平衡性能与功能。问题2滑块拖动后Y轴自动缩放导致趋势失真这是Plotly的默认行为yaxisdict(autorangeTrue)。用户拖到2090年时如果那年某年龄组占比突增到35%Y轴会拉到40%而2022年数据就压扁了。我的代码里强制range[0,40]但更优雅的方案是计算全量数据的Y轴范围y_min, y_max df[percentage].min(), df[percentage].max()然后设range[y_min*0.9, y_max*1.1]。这样既留白又不失真。问题3中文显示为方块Plotly默认字体不支持中文。解决方案是在fig.update_layout()里加fontdict(familySimHei, sans-serif, size12)。注意SimHei是Windows系统字体Mac用户需换成PingFang SCLinux用WenQuanYi Zen Hei。我测试时发现family参数必须是逗号分隔的字体栈不能只写一个否则跨平台失效。4.3 GPT-4协作中的认知偏差与修正策略偏差1过度工程化GPT-4有时会为简单任务生成复杂方案。比如处理“Age_0_4”列名它可能写一个完整的AgeGroupParser类而实际一行正则就够了。我的应对策略是在提示词末尾加一句“优先选择最简可行方案KISS原则避免不必要的抽象”。如果它还是写了类我就手动删掉只留核心正则。偏差2忽略边界条件GPT-4对“空数据”“全NaN列”“零行DataFrame”的处理很弱。它生成的prepare_plot_data()可能没有if df.empty: raise ValueError。我的修正策略是每次拿到代码先用df pd.DataFrame()构造空数据测试看是否崩溃再用df pd.DataFrame({year:[], age_group:[], percentage:[]})测试空列表。只有通过这两关的代码我才认为是健壮的。偏差3幻觉式API调用GPT-4可能编造不存在的Plotly参数比如xaxis_rangesliderdict(animatedTrue)实际没有animated参数。我的防御机制是运行前先查Plotly官方文档或用help(px.line)看参数列表。更高效的是在VS Code里装Plotly IntelliSense插件它会实时标红非法参数。注意所有GPT-4生成的代码必须经过“三步验证”① 语法检查python -m py_compile script.py② 单元测试用小样本数据跑通③ 业务校验找领域专家看图是否符合常识。我曾因跳过③让GPT-4把“80PLUS”当成“80-80”导致老龄化率计算错误被客户当场指出——这教训让我从此把业务校验列为强制步骤。5. 进阶扩展从单图到数据产品的工作流升级5.1 从脚本到可复用模块封装为PyPI包当你重复用这套流程处理5份以上数据时手写clean_data()就太低效了。我的做法是把它封装成一个轻量PyPI包plotly-slider-tools。核心是抽象出通用接口# plotly_slider_tools/core.py class DataProcessor: def __init__(self, skiprows: int 0, year_pattern: str r^\d{4}$): self.skiprows skiprows self.year_pattern year_pattern def clean_un_population(self, file_path: str) - pd.DataFrame: # 复用前面的clean_data逻辑但参数化 pass # plotly_slider_tools/plotting.py def create_range_slider( df: pd.DataFrame, x_col: str year, y_col: str percentage, color_col: str age_group, title: str Interactive Slider Chart, y_format: str .1f% ) - go.Figure: # 复用create_figure但支持任意列名 pass发布命令就一行python -m build twine upload dist/*。现在团队新人只要pip install plotly-slider-tools然后from plotly_slider_tools import create_range_slider3行代码搞定图表。这比教他们写提示词快10倍。5.2 从静态HTML到动态Web应用集成Dashfig.write_html()适合分享但无法响应用户输入。要实现“用户选择国家→自动加载该国数据→生成滑块”就得上Dash。我的Dash模板极简import dash from dash import dcc, html, Input, Output, State import plotly.express as px app dash.Dash(__name__) app.layout html.Div([ html.H1(联合国人口数据探索器), dcc.Dropdown( idcountry-dropdown, options[{label: 中国, value: CN}, {label: 印度, value: IN}], valueCN ), dcc.Graph(idslider-graph), dcc.Store(iddata-store) # 存储清洗后的数据 ]) app.callback( Output(data-store, data