joseph-data commited on
Commit
e4ffd46
·
verified ·
1 Parent(s): d7ca74c

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. .dockerignore +3 -0
  2. Dockerfile +0 -2
  3. app.py +164 -42
  4. data/scb_months_lvl1.parquet +2 -2
.dockerignore CHANGED
@@ -56,6 +56,9 @@ Thumbs.db
56
  # Frontend dependency caches (if present)
57
  node_modules/
58
 
 
 
 
59
  # Local cache / output files
60
  data/*
61
  !data/.gitkeep
 
56
  # Frontend dependency caches (if present)
57
  node_modules/
58
 
59
+ # Project-specific cruft
60
+ _brand.yml
61
+
62
  # Local cache / output files
63
  data/*
64
  !data/.gitkeep
Dockerfile CHANGED
@@ -34,9 +34,7 @@ ENV PATH="/app/.venv/bin:$PATH"
34
 
35
  # Copy only what the app needs at runtime
36
  COPY app.py ./app.py
37
- COPY _brand.yml ./_brand.yml
38
  COPY data ./data
39
- COPY logos ./logos
40
 
41
  # Requirement for deployment at hf
42
  EXPOSE 7860
 
34
 
35
  # Copy only what the app needs at runtime
36
  COPY app.py ./app.py
 
37
  COPY data ./data
 
38
 
39
  # Requirement for deployment at hf
40
  EXPOSE 7860
app.py CHANGED
@@ -12,6 +12,48 @@ MIN_POINTS_FOR_TRENDLINE = 2
12
  DATA_PATH = Path(__file__).parent / "data" / "scb_months_lvl1.parquet"
13
  LOGOS_PATH = Path(__file__).parent / "logos"
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  # --- Data Loading ---
17
  def load_data():
@@ -22,11 +64,9 @@ def load_data():
22
 
23
  df_full = load_data()
24
 
25
- # Identify metric columns
26
  daioe_metrics = [
27
  col for col in df_full.columns if col.startswith("daioe_") and col.endswith("_wavg")
28
  ]
29
- change_metrics = ["pct_chg_1m", "pct_chg_3m", "pct_chg_6m"]
30
  sexes = df_full["sex"].unique().to_list() if not df_full.is_empty() else []
31
  years = sorted(df_full["year"].unique().to_list()) if not df_full.is_empty() else []
32
  occupations = (
@@ -35,7 +75,22 @@ occupations = (
35
  else []
36
  )
37
 
38
- # --- Page Options ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  app_opts(static_assets={"/logos": LOGOS_PATH})
40
 
41
  ui.page_opts(
@@ -50,7 +105,6 @@ ui.tags.style("""
50
  justify-content: center;
51
  margin: 0.25rem 0 1rem;
52
  }
53
-
54
  .app-logo {
55
  width: min(180px, 80%);
56
  height: auto;
@@ -58,33 +112,28 @@ ui.tags.style("""
58
  }
59
  """)
60
 
 
61
  # --- Sidebar ---
62
- with ui.sidebar():
63
  ui.div(
64
  ui.img(src="/logos/lab.svg", alt="AI-Econ Lab logo", class_="app-logo"),
65
  class_="app-logo-wrap",
66
  )
67
  ui.input_select(
68
  "ai_metric",
69
- "Select AI Exposure Metric (Weighted Avg)",
70
- choices={
71
- m: m.replace("daioe_", "").replace("_wavg", "").title()
72
- for m in daioe_metrics
73
- },
74
- selected=daioe_metrics[-1] if daioe_metrics else None,
75
  )
76
  ui.input_select(
77
  "change_horizon",
78
- "Select Employment Change Horizon",
79
- choices={
80
- m: m.replace("pct_chg_", "").replace("m", " Month").title()
81
- for m in change_metrics
82
- },
83
  selected="pct_chg_3m",
84
  )
85
  ui.input_slider(
86
  "year_filter",
87
- "Filter by Year",
88
  min=min(years) if years else 2015,
89
  max=max(years) if years else 2026,
90
  value=[min(years), max(years)] if years else [2015, 2026],
@@ -92,21 +141,25 @@ with ui.sidebar():
92
  )
93
  ui.input_checkbox_group(
94
  "sex_filter",
95
- "Filter by Sex",
96
  choices=sexes,
97
  selected=sexes,
98
  )
99
  ui.input_selectize(
100
  "occ_filter",
101
- "Filter by Occupation (Leave blank for all)",
102
  choices=occupations,
103
  multiple=True,
104
  )
105
  ui.hr()
106
  ui.markdown("""
107
- **About this Dashboard**
108
- This app visualizes the relationship between AI Occupational Exposure (DAIOE)
109
- and monthly employment changes in Sweden.
 
 
 
 
110
  """)
111
 
112
 
@@ -128,70 +181,139 @@ def filtered_df():
128
  return df
129
 
130
 
131
- # --- Main Layout ---
132
  with ui.layout_columns(fill=False):
133
  with ui.value_box(theme="primary"):
134
- "Avg Exposure"
135
 
136
  @render.text
137
  def avg_exposure():
138
  df = filtered_df()
139
  if df.is_empty():
140
- return "0.0"
141
  val = df[app_input.ai_metric()].mean()
142
- return f"{val:.2f}"
 
 
 
 
 
143
 
144
  with ui.value_box(theme="secondary"):
145
- "Median % Change"
146
 
147
  @render.text
148
  def median_change():
149
  df = filtered_df()
150
  if df.is_empty():
151
- return "0.0%"
152
  val = df[app_input.change_horizon()].median()
153
  return f"{val:+.2f}%"
154
 
 
 
 
 
 
 
 
155
  with ui.value_box(theme="info"):
156
- "Observation Count"
157
 
158
  @render.text
159
  def obs_count():
160
  return f"{len(filtered_df()):,}"
161
 
 
 
 
 
 
162
 
 
163
  with ui.card(full_screen=True):
164
- ui.card_header("AI Exposure vs. Employment Change")
 
 
 
 
165
 
166
  @render_widget
167
  def scatter_plot():
168
  df = filtered_df().to_pandas()
 
 
 
 
 
169
  if df.empty:
170
- return px.scatter(title="No data available for selected filters")
 
 
171
 
172
  fig = px.scatter(
173
  df,
174
- x=app_input.ai_metric(),
175
- y=app_input.change_horizon(),
176
  color="occupation",
177
  size="emp_count" if "emp_count" in df.columns else None,
178
- hover_data=["month", "sex", "emp_count"],
179
  labels={
180
- app_input.ai_metric(): "AI Exposure Score",
181
- app_input.change_horizon(): "% Change in Employment",
 
 
 
 
 
182
  },
 
183
  template="plotly_white",
184
- opacity=0.7,
185
- trendline="ols" if len(df) > MIN_POINTS_FOR_TRENDLINE else None,
186
- trendline_scope="overall" if len(df) > MIN_POINTS_FOR_TRENDLINE else None,
187
  )
188
- fig.update_layout(legend_title_text="Occupation")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
189
  return fig
190
 
191
 
 
192
  with ui.card(full_screen=True):
193
- ui.card_header("Filtered Data Table")
194
 
195
  @render.data_frame
196
  def data_table():
197
- return render.DataGrid(filtered_df().to_pandas())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  DATA_PATH = Path(__file__).parent / "data" / "scb_months_lvl1.parquet"
13
  LOGOS_PATH = Path(__file__).parent / "logos"
14
 
15
+ # Brand-aligned color sequence for occupation dots
16
+ BRAND_COLORS = [
17
+ "#4D6CFA", # violet (primary accent)
18
+ "#BA274A", # red
19
+ "#5BC0BE", # teal
20
+ "#F9A03F", # amber
21
+ "#8B5CF6", # purple
22
+ "#0C0A3E", # deep blue
23
+ "#E8A838", # gold
24
+ "#6B9BC3", # steel blue
25
+ "#2A2E45", # gray-blue
26
+ ]
27
+
28
+ # Human-readable labels for DAIOE weighted-average metrics
29
+ METRIC_LABELS = {
30
+ "daioe_allapps_wavg": "All AI Applications",
31
+ "daioe_stratgames_wavg": "Strategic Games",
32
+ "daioe_videogames_wavg": "Video Games",
33
+ "daioe_imgrec_wavg": "Image Recognition",
34
+ "daioe_imgcompr_wavg": "Image Compression",
35
+ "daioe_imggen_wavg": "Image Generation",
36
+ "daioe_readcompr_wavg": "Reading Comprehension",
37
+ "daioe_lngmod_wavg": "Language Models",
38
+ "daioe_translat_wavg": "Translation",
39
+ "daioe_speechrec_wavg": "Speech Recognition",
40
+ "daioe_genai_wavg": "Generative AI",
41
+ }
42
+
43
+ HORIZON_LABELS = {
44
+ "pct_chg_1m": "1 Month",
45
+ "pct_chg_3m": "3 Months",
46
+ "pct_chg_6m": "6 Months",
47
+ }
48
+
49
+ # Columns shown in the data table — prioritise the selected metric & horizon,
50
+ # then a curated set of DAIOE weighted averages (avoids dumping all 68 cols).
51
+ TABLE_BASE_COLS = [
52
+ "year", "month", "sex", "occupation", "emp_count",
53
+ "pct_chg_1m", "pct_chg_3m", "pct_chg_6m",
54
+ ]
55
+ TABLE_DAIOE_COLS = list(METRIC_LABELS.keys())
56
+
57
 
58
  # --- Data Loading ---
59
  def load_data():
 
64
 
65
  df_full = load_data()
66
 
 
67
  daioe_metrics = [
68
  col for col in df_full.columns if col.startswith("daioe_") and col.endswith("_wavg")
69
  ]
 
70
  sexes = df_full["sex"].unique().to_list() if not df_full.is_empty() else []
71
  years = sorted(df_full["year"].unique().to_list()) if not df_full.is_empty() else []
72
  occupations = (
 
75
  else []
76
  )
77
 
78
+ # Build metric choice dict — fall back gracefully for any unmapped columns
79
+ metric_choices = {
80
+ m: METRIC_LABELS.get(
81
+ m,
82
+ m.replace("daioe_", "").replace("_wavg", "").replace("_", " ").title(),
83
+ )
84
+ for m in daioe_metrics
85
+ }
86
+
87
+ default_metric = (
88
+ "daioe_allapps_wavg" if "daioe_allapps_wavg" in daioe_metrics
89
+ else (daioe_metrics[-1] if daioe_metrics else None)
90
+ )
91
+
92
+
93
+ # --- Page Setup ---
94
  app_opts(static_assets={"/logos": LOGOS_PATH})
95
 
96
  ui.page_opts(
 
105
  justify-content: center;
106
  margin: 0.25rem 0 1rem;
107
  }
 
108
  .app-logo {
109
  width: min(180px, 80%);
110
  height: auto;
 
112
  }
113
  """)
114
 
115
+
116
  # --- Sidebar ---
117
+ with ui.sidebar(title="Filters"):
118
  ui.div(
119
  ui.img(src="/logos/lab.svg", alt="AI-Econ Lab logo", class_="app-logo"),
120
  class_="app-logo-wrap",
121
  )
122
  ui.input_select(
123
  "ai_metric",
124
+ "AI Exposure Metric",
125
+ choices=metric_choices,
126
+ selected=default_metric,
 
 
 
127
  )
128
  ui.input_select(
129
  "change_horizon",
130
+ "Employment Change Horizon",
131
+ choices=HORIZON_LABELS,
 
 
 
132
  selected="pct_chg_3m",
133
  )
134
  ui.input_slider(
135
  "year_filter",
136
+ "Year Range",
137
  min=min(years) if years else 2015,
138
  max=max(years) if years else 2026,
139
  value=[min(years), max(years)] if years else [2015, 2026],
 
141
  )
142
  ui.input_checkbox_group(
143
  "sex_filter",
144
+ "Sex",
145
  choices=sexes,
146
  selected=sexes,
147
  )
148
  ui.input_selectize(
149
  "occ_filter",
150
+ "Occupation (blank = all)",
151
  choices=occupations,
152
  multiple=True,
153
  )
154
  ui.hr()
155
  ui.markdown("""
156
+ **About**
157
+
158
+ This dashboard visualizes the relationship between AI Occupational Exposure
159
+ (DAIOE) and employment changes across Swedish occupational categories.
160
+
161
+ Data: [Statistics Sweden (SCB)](https://www.scb.se) &
162
+ DAIOE scores via the AI-Econ Lab.
163
  """)
164
 
165
 
 
181
  return df
182
 
183
 
184
+ # --- KPI Cards ---
185
  with ui.layout_columns(fill=False):
186
  with ui.value_box(theme="primary"):
187
+ "Avg AI Exposure"
188
 
189
  @render.text
190
  def avg_exposure():
191
  df = filtered_df()
192
  if df.is_empty():
193
+ return ""
194
  val = df[app_input.ai_metric()].mean()
195
+ return f"{val:.3f}"
196
+
197
+ ui.p(
198
+ "Weighted average DAIOE score",
199
+ style="font-size:0.8rem; opacity:0.85; margin:0;",
200
+ )
201
 
202
  with ui.value_box(theme="secondary"):
203
+ "Median Employment Change"
204
 
205
  @render.text
206
  def median_change():
207
  df = filtered_df()
208
  if df.is_empty():
209
+ return ""
210
  val = df[app_input.change_horizon()].median()
211
  return f"{val:+.2f}%"
212
 
213
+ @render.ui
214
+ def median_change_label():
215
+ return ui.p(
216
+ f"Over {HORIZON_LABELS.get(app_input.change_horizon(), '')}",
217
+ style="font-size:0.8rem; opacity:0.85; margin:0;",
218
+ )
219
+
220
  with ui.value_box(theme="info"):
221
+ "Observations"
222
 
223
  @render.text
224
  def obs_count():
225
  return f"{len(filtered_df()):,}"
226
 
227
+ ui.p(
228
+ "Data points after filtering",
229
+ style="font-size:0.8rem; opacity:0.85; margin:0;",
230
+ )
231
+
232
 
233
+ # --- Scatter Plot ---
234
  with ui.card(full_screen=True):
235
+ @render.ui
236
+ def scatter_header():
237
+ metric_label = metric_choices.get(app_input.ai_metric(), app_input.ai_metric())
238
+ horizon_label = HORIZON_LABELS.get(app_input.change_horizon(), app_input.change_horizon())
239
+ return ui.card_header(f"{metric_label} vs. {horizon_label} Employment Change")
240
 
241
  @render_widget
242
  def scatter_plot():
243
  df = filtered_df().to_pandas()
244
+ metric = app_input.ai_metric()
245
+ horizon = app_input.change_horizon()
246
+ metric_label = metric_choices.get(metric, metric)
247
+ horizon_label = HORIZON_LABELS.get(horizon, horizon)
248
+
249
  if df.empty:
250
+ return px.scatter(title="No data available for the selected filters.")
251
+
252
+ use_trendline = len(df) > MIN_POINTS_FOR_TRENDLINE
253
 
254
  fig = px.scatter(
255
  df,
256
+ x=metric,
257
+ y=horizon,
258
  color="occupation",
259
  size="emp_count" if "emp_count" in df.columns else None,
260
+ hover_data=["month", "year", "sex", "emp_count"],
261
  labels={
262
+ metric: f"AI Exposure Score — {metric_label}",
263
+ horizon: f"% Employment Change ({horizon_label})",
264
+ "occupation": "Occupation",
265
+ "emp_count": "Employment",
266
+ "month": "Month",
267
+ "year": "Year",
268
+ "sex": "Sex",
269
  },
270
+ color_discrete_sequence=BRAND_COLORS,
271
  template="plotly_white",
272
+ opacity=0.72,
273
+ trendline="ols" if use_trendline else None,
274
+ trendline_scope="overall" if use_trendline else None,
275
  )
276
+
277
+ fig.update_layout(
278
+ legend_title_text="Occupation",
279
+ font_family="Nunito Sans",
280
+ title_font_family="Montserrat",
281
+ plot_bgcolor="#FFFFFF",
282
+ paper_bgcolor="#FFFFFF",
283
+ legend={
284
+ "bgcolor": "rgba(249,247,241,0.9)",
285
+ "bordercolor": "#E0DDD6",
286
+ "borderwidth": 1,
287
+ },
288
+ margin={"l": 60, "r": 30, "t": 40, "b": 60},
289
+ )
290
+
291
+ if use_trendline:
292
+ fig.update_traces(
293
+ selector={"mode": "lines"},
294
+ line={"color": "#0C0A3E", "width": 2, "dash": "dot"},
295
+ )
296
+
297
  return fig
298
 
299
 
300
+ # --- Data Table ---
301
  with ui.card(full_screen=True):
302
+ ui.card_header("Filtered Data")
303
 
304
  @render.data_frame
305
  def data_table():
306
+ df = filtered_df()
307
+ if df.is_empty():
308
+ return render.DataGrid(df.to_pandas())
309
+
310
+ metric = app_input.ai_metric()
311
+ horizon = app_input.change_horizon()
312
+
313
+ # Selected metric + horizon come first, then remaining base cols, then other DAIOE wavgs
314
+ priority = ["year", "month", "sex", "occupation", "emp_count", metric, horizon]
315
+ rest_daioe = [c for c in TABLE_DAIOE_COLS if c not in priority and c in df.columns]
316
+ rest_base = [c for c in TABLE_BASE_COLS if c not in priority and c in df.columns]
317
+ display_cols = [c for c in priority + rest_base + rest_daioe if c in df.columns]
318
+
319
+ return render.DataGrid(df.select(display_cols).to_pandas(), filters=True)
data/scb_months_lvl1.parquet CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:b44c3ea17b685243d1d7af5bdbac1a82e1414938fbdfe222d7a3024d3332808e
3
- size 168138
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e022b2f932438566e431649210e8838551a400f10b3c109610bb3d83dcf7c0a
3
+ size 167890