Spaces:
Sleeping
Sleeping
Sync from GitHub via hub-sync
Browse files- README.md +8 -7
- app.py +104 -1
- src/calcs.py +44 -0
- 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
|
| 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**
|
| 18 |
-
- **AI Exposure**
|
| 19 |
-
- **Employment by Age Group**
|
| 20 |
-
- **
|
|
|
|
| 21 |
|
| 22 |
## Data Sources
|
| 23 |
|
| 24 |
| Data | Source |
|
| 25 |
|------|--------|
|
| 26 |
-
| AI Exposure Index | [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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|