joseph-data commited on
Commit
d2751bb
·
verified ·
1 Parent(s): 0a13764

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. README.md +8 -7
  2. app.py +104 -1
  3. src/calcs.py +44 -0
  4. src/visuals.py +52 -0
README.md CHANGED
@@ -8,20 +8,21 @@ app_file: app.py
8
  pinned: false
9
  ---
10
 
11
- # DAIOE Years Explorer Swedish Occupations
12
 
13
  An interactive Shiny app for exploring AI exposure and employment trends across Swedish occupations, built on the [DAIOE](https://www.ai-econlab.com/ai-exposure-daioe) index linked to [SCB's Swedish Occupational Register](https://www.scb.se/en/finding-statistics/statistics-by-subject-area/labour-market/labour-force-supply/the-swedish-occupational-register-with-statistics/).
14
 
15
  ## Features
16
 
17
- - **Occupation View** Select an occupation by SSYK level and year to see employment statistics and % changes (1, 3, 5-year)
18
- - **AI Exposure** Ranked bar chart of AI sub-domain exposure scores with percentile rankings and exposure levels
19
- - **Employment by Age Group** Line chart of annual employment % change by age group over a custom year range
20
- - **Download** Filter and export the full dataset as CSV, Parquet, or Excel
 
21
 
22
  ## Data Sources
23
 
24
  | Data | Source |
25
  |------|--------|
26
- | AI Exposure Index | [DAIOE AI Econ Lab](https://www.ai-econlab.com/ai-exposure-daioe) |
27
- | Employment Statistics | [Swedish Occupational Register, SCB](https://www.scb.se/en/finding-statistics/statistics-by-subject-area/labour-market/labour-force-supply/the-swedish-occupational-register-with-statistics/)
 
8
  pinned: false
9
  ---
10
 
11
+ # DAIOE Years Explorer: Swedish Occupations
12
 
13
  An interactive Shiny app for exploring AI exposure and employment trends across Swedish occupations, built on the [DAIOE](https://www.ai-econlab.com/ai-exposure-daioe) index linked to [SCB's Swedish Occupational Register](https://www.scb.se/en/finding-statistics/statistics-by-subject-area/labour-market/labour-force-supply/the-swedish-occupational-register-with-statistics/).
14
 
15
  ## Features
16
 
17
+ - **Occupation View**: Select an occupation by SSYK level and year to see employment statistics and % changes (1, 3, 5-year)
18
+ - **AI Exposure**: Ranked bar chart of AI sub-domain exposure scores with percentile rankings and exposure levels
19
+ - **Employment by Age Group**: Line chart of annual employment % change by age group over a custom year range
20
+ - **Comparison View**: Benchmark multiple occupations side by side with an employment trend chart, summary table, and AI exposure radar chart
21
+ - **Download**: Filter and export the full dataset as CSV, Parquet, or Excel
22
 
23
  ## Data Sources
24
 
25
  | Data | Source |
26
  |------|--------|
27
+ | AI Exposure Index | [DAIOE - AI Econ Lab](https://www.ai-econlab.com/ai-exposure-daioe) |
28
+ | Employment Statistics | [Swedish Occupational Register, SCB](https://www.scb.se/en/finding-statistics/statistics-by-subject-area/labour-market/labour-force-supply/the-swedish-occupational-register-with-statistics/) |
app.py CHANGED
@@ -19,6 +19,7 @@ from src.setup import (
19
  build_choices_by_level,
20
  download_extension,
21
  download_media_type,
 
22
  export_filtered_data,
23
  lf,
24
  )
@@ -76,6 +77,25 @@ def occ_summary():
76
 
77
 
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  @reactive.calc
80
  def occ_employment_by_age():
81
  """Reactive wrapper: returns long-format employment by age group for the line chart."""
@@ -168,7 +188,79 @@ with ui.navset_pill(id="tab"):
168
  "Card 4"
169
 
170
  with ui.nav_panel(title="2. Comparison View"):
171
- "Panel B content"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
  with ui.nav_panel(title="3. Download"):
174
  ui.p(
@@ -273,6 +365,17 @@ def _sync_occupation_choices():
273
  ui.update_selectize("occupation", choices=choices, selected=next(iter(choices)))
274
 
275
 
 
 
 
 
 
 
 
 
 
 
 
276
  @reactive.effect
277
  def _sync_download_occupation_choices():
278
  """Update the download occupation selectize when the download SSYK level changes."""
 
19
  build_choices_by_level,
20
  download_extension,
21
  download_media_type,
22
+ empty_figure,
23
  export_filtered_data,
24
  lf,
25
  )
 
77
 
78
 
79
 
80
+ @reactive.calc
81
+ def comparison_data():
82
+ """Return total employment per year/occupation for the comparison view."""
83
+ occs = list(input.comp_occs())
84
+ ages = list(input.comp_age())
85
+ if not occs or not ages:
86
+ return pl.DataFrame()
87
+ return calcs.get_comparison_employment(lf, occs, ages)
88
+
89
+
90
+ @reactive.calc
91
+ def comp_radar_data():
92
+ """Return mean AI percentile scores per occupation for the radar chart."""
93
+ occs = list(input.comp_occs())
94
+ if not occs:
95
+ return pl.DataFrame()
96
+ return calcs.get_comp_radar(lf, occs, int(input.comp_year()))
97
+
98
+
99
  @reactive.calc
100
  def occ_employment_by_age():
101
  """Reactive wrapper: returns long-format employment by age group for the line chart."""
 
188
  "Card 4"
189
 
190
  with ui.nav_panel(title="2. Comparison View"):
191
+ with ui.layout_sidebar():
192
+ with ui.sidebar(bg="#FFFFFF", width=300, title="Benchmarking"):
193
+ ui.input_select("comp_level", "SSYK Level", choices=["All Levels", *LEVELS], selected=DEFAULT_LEVEL)
194
+ ui.input_selectize("comp_occs", "Select Occupations", choices={}, multiple=True)
195
+ ui.hr()
196
+ ui.input_selectize("comp_age", "Age Group", choices=AGES, selected="Early Career 2 (25-29)", multiple=True)
197
+ ui.hr()
198
+ ui.input_select("comp_year", "Comparison Year (Radar)", choices=[str(y) for y in YEARS], selected=str(YEAR_MAX))
199
+
200
+ with ui.card():
201
+ ui.card_header("Benchmarking Summary")
202
+
203
+ @render.ui
204
+ def comparison_summary():
205
+ df = comparison_data()
206
+ if df.is_empty():
207
+ return ui.markdown("*Select occupations to generate a summary...*")
208
+
209
+ latest_yr = df["year"].max()
210
+ summary_rows = []
211
+ for occ in df["occupation"].unique():
212
+ sub = df.filter(pl.col("occupation") == occ).sort("year")
213
+ curr_emp = sub.tail(1)["count"][0]
214
+
215
+ def _val(yr, _sub=sub):
216
+ s = _sub.filter(pl.col("year") == yr)["count"]
217
+ return f"{int(s[0]):,}" if not s.is_empty() else "---"
218
+
219
+ summary_rows.append(
220
+ ui.tags.tr(
221
+ ui.tags.td(occ, style="font-weight: bold;"),
222
+ ui.tags.td(_val(latest_yr - 5)),
223
+ ui.tags.td(_val(latest_yr - 3)),
224
+ ui.tags.td(_val(latest_yr - 1)),
225
+ ui.tags.td(f"{int(curr_emp):,}", style="background-color: #f8f9fa; font-weight: bold;"),
226
+ ),
227
+ )
228
+
229
+ return ui.tags.table(
230
+ ui.tags.thead(
231
+ ui.tags.tr(
232
+ ui.tags.th("Occupation"),
233
+ ui.tags.th(f"Emp ({latest_yr - 5})"),
234
+ ui.tags.th(f"Emp ({latest_yr - 3})"),
235
+ ui.tags.th(f"Emp ({latest_yr - 1})"),
236
+ ui.tags.th(f"Emp ({latest_yr})"),
237
+ ),
238
+ ),
239
+ ui.tags.tbody(*summary_rows),
240
+ class_="table table-sm table-hover",
241
+ style="font-size: 0.9rem;",
242
+ )
243
+
244
+ with ui.layout_columns(col_widths=[6, 6], gap="1rem"):
245
+ with ui.card(full_screen=True):
246
+ ui.card_header("Employment Trends (Selected Occupations)")
247
+
248
+ @render_plotly
249
+ def comparison_employment_plot():
250
+ df = comparison_data()
251
+ if df.is_empty():
252
+ return empty_figure("Select occupations to compare", {"text": "#666"})
253
+ return visuals.build_comparison_employment_plot(df.to_pandas())
254
+
255
+ with ui.card(full_screen=True):
256
+ ui.card_header("Radar Comparison (AI Exposure Percentiles)")
257
+
258
+ @render_plotly
259
+ def comp_radar_plot():
260
+ df = comp_radar_data()
261
+ if df.is_empty():
262
+ return empty_figure("Select occupations to compare", {"text": "#666"})
263
+ return visuals.build_comp_radar_plot(df.to_pandas(), METRICS)
264
 
265
  with ui.nav_panel(title="3. Download"):
266
  ui.p(
 
365
  ui.update_selectize("occupation", choices=choices, selected=next(iter(choices)))
366
 
367
 
368
+ @reactive.effect
369
+ def _sync_comp_occupation_choices():
370
+ """Update comparison occupation choices when the SSYK level changes."""
371
+ level = input.comp_level()
372
+ if level == "All Levels":
373
+ choices = {occ: occ for d in OCCUPATION_CHOICES.values() for occ in d}
374
+ else:
375
+ choices = OCCUPATION_CHOICES.get(level, {})
376
+ ui.update_selectize("comp_occs", choices=choices, selected=[])
377
+
378
+
379
  @reactive.effect
380
  def _sync_download_occupation_choices():
381
  """Update the download occupation selectize when the download SSYK level changes."""
src/calcs.py CHANGED
@@ -122,6 +122,50 @@ def get_occ_ai_trend(
122
  )
123
 
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  def get_occ_employment_by_age(
126
  lf: pl.LazyFrame,
127
  occupation: str,
 
122
  )
123
 
124
 
125
+ def get_comparison_employment(
126
+ lf: pl.LazyFrame,
127
+ occupations: list[str],
128
+ age_groups: list[str],
129
+ ) -> pl.DataFrame:
130
+ """
131
+ Return total employment per year/occupation for the comparison view.
132
+
133
+ Aggregates across all sexes and the selected age groups.
134
+ Returns a DataFrame with columns: year, occupation, count.
135
+ """
136
+ return (
137
+ lf.filter(
138
+ pl.col("occupation").is_in(occupations)
139
+ & pl.col("age_group").is_in(age_groups),
140
+ )
141
+ .group_by(["year", "occupation"])
142
+ .agg(pl.col("count").sum())
143
+ .sort(["occupation", "year"])
144
+ .collect()
145
+ )
146
+
147
+
148
+ def get_comp_radar(
149
+ lf: pl.LazyFrame,
150
+ occupations: list[str],
151
+ year: int,
152
+ ) -> pl.DataFrame:
153
+ """
154
+ Return mean AI percentile scores per occupation for the radar chart.
155
+
156
+ Returns a DataFrame with columns: occupation, pctl_<metric>_wavg for each metric.
157
+ """
158
+ return (
159
+ lf.filter(
160
+ pl.col("occupation").is_in(occupations)
161
+ & (pl.col("year") == year),
162
+ )
163
+ .group_by("occupation")
164
+ .agg([pl.col(c).mean() for c in AI_PCTL_COLS])
165
+ .collect()
166
+ )
167
+
168
+
169
  def get_occ_employment_by_age(
170
  lf: pl.LazyFrame,
171
  occupation: str,
src/visuals.py CHANGED
@@ -137,6 +137,58 @@ def build_age_chart(df: pd.DataFrame, occupation: str) -> go.Figure:
137
  return fig
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def build_ai_exposure_bar(df: pd.DataFrame, occupation: str, year: int) -> go.Figure:
141
  """
142
  Build a vertical bar chart of AI exposure level per sub-domain.
 
137
  return fig
138
 
139
 
140
+ def build_comparison_employment_plot(df: pd.DataFrame) -> go.Figure:
141
+ """Build a line chart comparing employment trends across selected occupations."""
142
+ if df.empty:
143
+ return go.Figure()
144
+
145
+ fig = px.line(
146
+ df,
147
+ x="year",
148
+ y="count",
149
+ color="occupation",
150
+ markers=True,
151
+ labels={"count": "Total Employment", "year": "Year"},
152
+ )
153
+ fig.update_layout(
154
+ **_BASE_LAYOUT,
155
+ legend={"orientation": "h", "yanchor": "bottom", "y": -0.25, "xanchor": "center", "x": 0.5, "title": None},
156
+ )
157
+ fig.update_xaxes(gridcolor=_C_GRID, zeroline=False, dtick=1)
158
+ fig.update_yaxes(gridcolor=_C_GRID, zeroline=False)
159
+ return fig
160
+
161
+
162
+ def build_comp_radar_plot(df: pd.DataFrame, metrics: dict[str, str]) -> go.Figure:
163
+ """Build a radar chart comparing AI percentile scores across selected occupations."""
164
+ if df.empty:
165
+ return go.Figure()
166
+
167
+ categories = list(metrics.values())
168
+ fig = go.Figure()
169
+
170
+ for _, row in df.iterrows():
171
+ r_values = [row[f"pctl_{k}_wavg"] for k in metrics]
172
+ r_values_closed = [*r_values, r_values[0]]
173
+ categories_closed = [*categories, categories[0]]
174
+
175
+ fig.add_trace(go.Scatterpolar(
176
+ r=r_values_closed,
177
+ theta=categories_closed,
178
+ fill="toself",
179
+ name=row["occupation"],
180
+ hovertemplate="%{theta}: %{r:.1f}%<extra></extra>",
181
+ ))
182
+
183
+ fig.update_layout(
184
+ **_BASE_LAYOUT,
185
+ polar={"radialaxis": {"visible": True, "range": [0, 100]}},
186
+ showlegend=True,
187
+ legend={"orientation": "h", "yanchor": "bottom", "y": -0.25, "xanchor": "center", "x": 0.5},
188
+ )
189
+ return fig
190
+
191
+
192
  def build_ai_exposure_bar(df: pd.DataFrame, occupation: str, year: int) -> go.Figure:
193
  """
194
  Build a vertical bar chart of AI exposure level per sub-domain.