Ranked Movers & Outlier Interpretation

Lesson 18 — Understanding ranked movers data

What Ranked Movers Shows

The Ranked Movers page answers "which occupations changed the most?" for a given metric and year. Top gainers are occupations with the highest year-over-year percent change; top losers have the lowest (most negative). The user selects a metric (employment count, mean wage, median wage) and a year — the results change for each combination.

This makes Ranked Movers a discovery tool rather than a fixed report. A user exploring wage trends will see a completely different set of occupations than one exploring employment shifts, even for the same year. The rankings surface occupations that might not appear on anyone's radar through conventional browsing.

The Dual-Direction Sort

Gainers and losers come from the same query — only the sort direction changes. The key join is through base_metric_key, which connects each YoY percent change back to the specific base metric the user selected.

-- Same query, only ORDER BY direction changes
movers_sql = """
    SELECT o.soc_code, o.occupation_title,
           d.derived_value AS pct_change,
           abs.derived_value AS abs_change
    FROM fact_derived_series d
    JOIN dim_metric m ON d.metric_key = m.metric_key
    JOIN dim_metric bm ON d.base_metric_key = bm.metric_key
    JOIN dim_occupation o ON d.occupation_key = o.occupation_key
    ...
    WHERE m.metric_name = 'yoy_percent_change'
      AND bm.metric_name = ?   -- user-selected base metric
      AND tp.year = ?
    ORDER BY d.derived_value {direction}
    LIMIT ?
"""
gainers = execute(movers_sql.format(direction="DESC"), params)
losers  = execute(movers_sql.format(direction="ASC"), params)

The key is bm.metric_name = ? — this joins through base_metric_key to filter YoY percent changes for only the selected base metric. The dim_metric table is joined twice: once for the derived metric itself (m) and once for the base metric it was derived from (bm). Different base metrics produce completely different rankings because the underlying values and their volatility profiles differ.

Small-Denominator Volatility

Percentage changes on small bases are statistically volatile. An occupation with 100 workers gaining 50 shows +50% YoY change, outranking an occupation with 100,000 workers gaining 5,000 (+5%). The percentage is mathematically correct in both cases, but the economic significance is vastly different.

# Both are "real" changes, but one is much more meaningful:
Dancers:                3,850 → 8,930   (+131.9%)  # Volatile
Software Developers:  1,795,300 → 1,847,800  (+2.9%)   # Stable

The API returns both pct_change and abs_change so the UI shows absolute context alongside percentages. A +131.9% change in 5,080 jobs is less economically significant than a +2.9% change in 52,500 jobs. Users see both numbers side by side and can judge for themselves which changes represent meaningful labor market shifts versus statistical noise in small populations.

Suppressed Data Creates Apparent Outliers

When BLS suppresses a value in year N but not year N+1 (or vice versa), the YoY calculation is NULL — since the pipeline never imputes, there is simply no derived row. But when suppression is lifted, the "first visible" year may show extreme apparent changes because it is being compared to a period with very different conditions.

For example, if an occupation's wage was suppressed in 2021 and published in 2022, there is no 2021→2022 change. But the 2022→2023 change is computed normally — and if the 2022 value was unusually low (the reason it was near the suppression threshold), the 2023 change may appear artificially large.

Important: Always check absolute changes alongside percentages. A +200% wage increase for Broadcast Announcers reflects small-sample volatility in a niche occupation, not a genuine doubling of the profession's pay.

Year and Metric as Filters

The API returns available_years and defaults to the latest year. Users can select any combination of metric and year to explore different facets of the labor market. The static site pre-generates separate JSON files for each combination so the client-side shim can serve the correct data without a live server.

movers.json                           # default (employment_count, latest year)
movers_mean_annual_wage.json          # mean wage, latest year
movers-2022.json                      # employment count, 2022
movers-2022_median_annual_wage.json   # median wage, 2022

This URL-to-file mapping is how the static site shim serves different data per metric and year — each combination is pre-generated at build time by build_static.py. The naming convention encodes both the year and the metric in the filename. When the JavaScript requests /api/trends/movers?year=2022&metric=median_annual_wage, the shim translates it to movers-2022_median_annual_wage.json and returns the pre-built response.