SHOW:
|
|
- or go back to the newest paste.
1 | # Data sources: | |
2 | - | # New study permits: 40k–60k |
2 | + | # New study permits: 40k-60k |
3 | - | # New work permits: 280k–360k |
3 | + | # New work permits: 280k-360k |
4 | # Study permits (new): capped at 437k for 2025. H1 likely 40k-60k new approvals (cap + seasonality). | |
5 | # Work permits (new): ~825k finalized Jan-Jul, but 40-50% are renewals. H1 ~280k-360k new approvals. | |
6 | # Combined H1 2025 “new” study+work ≈ 320k-420k. | |
7 | # StatsCan shows NPR stock fell in Q1 2025, meaning exits/extensions outweighed new arrivals. | |
8 | # https://www.canada.ca/en/immigration-refugees-citizenship/corporate/mandate/corporate-initiatives/levels/inventories-backlogs.html | |
9 | # https://www.canada.ca/en/immigration-refugees-citizenship/news/notices/2025-provincial-territorial-allocations-under-international-student-cap.html https://open.canada.ca/data/en/dataset/9b34e712-513f-44e9-babf-9df4f7256550 | |
10 | # https://open.canada.ca/data/en/dataset/90115b00-f9b8-49e8-afa3-b4cff8facaee | |
11 | # https://open.canada.ca/data/en/dataset/360024f2-17e9-4558-bfc1-3616485d65b9 | |
12 | # https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1710012101 | |
13 | # https://www150.statcan.gc.ca/n1/daily-quotidien/250618/dq250618a-eng.htm | |
14 | # Births: https://www150.statcan.gc.ca/t1/tbl1/en/dtl!downloadDbLoadingData-nonTraduit.action?pid=1310041501&latestN=0&startDate=20000101&endDate=99990101&csvLocale=en&selectedMembers=%5B%5B1%5D%2C%5B%5D%2C%5B1%5D%5D&checkedLevels=1D1%2C1D2 (https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1310041501) - 351,878 | |
15 | # Deaths: https://www150.statcan.gc.ca/t1/tbl1/en/dtl!downloadDbLoadingData-nonTraduit.action?pid=1310070801&latestN=0&startDate=20000101&endDate=99990101&csvLocale=en&selectedMembers=%5B%5B1%5D%2C%5B1%2C2%2C3%2C4%2C5%2C6%2C7%2C8%2C9%2C10%2C11%2C12%2C13%5D%2C%5B1%2C2%5D%5D&checkedLevels= (https://www150.statcan.gc.ca/t1/tbl1/en/tv.action?pid=1310070801) - 326,215 | |
16 | ||
17 | import matplotlib.pyplot as plt | |
18 | from matplotlib.lines import Line2D | |
19 | - | import numpy as np |
19 | + | |
20 | TOTAL_POP = 41_290_000 | |
21 | BIRTHS = 351_878 | |
22 | - | total_population = 41_290_000 |
22 | + | DEATHS = 326_215 |
23 | - | births = 351_878 |
23 | + | OBSERVED_POP_CHANGE = 20_107 |
24 | - | deaths = 326_215 |
24 | + | NATURAL_INCREASE = BIRTHS - DEATHS |
25 | - | observed_pop_change = 20_107 |
25 | + | |
26 | - | natural_increase = births - deaths |
26 | + | NEW_STUDY_RANGE = (40_000, 60_000) |
27 | NEW_WORK_RANGE = (280_000, 360_000) | |
28 | - | new_study_range = (40_000, 60_000) |
28 | + | RENEWED_WORK_RANGE = (330_000, 410_000) |
29 | - | new_work_range = (280_000, 360_000) |
29 | + | |
30 | - | renewed_work_range = (330_000, 410_000) |
30 | + | STUDENT_EXPIRY_SHARE_RANGE = (0.30, 0.40) |
31 | - | new_visas_range = (new_study_range[0] + new_work_range[0], new_study_range[1] + new_work_range[1]) |
31 | + | WORK_EXPIRY_SHARE_RANGE = (0.60, 0.70) |
32 | ||
33 | - | expiries_total_range = ( |
33 | + | def midpoint(lo: int, hi: int) -> float: |
34 | - | natural_increase + new_visas_range[0] + renewed_work_range[0] - observed_pop_change, |
34 | + | return (lo + hi) / 2.0 |
35 | - | natural_increase + new_visas_range[1] + renewed_work_range[1] - observed_pop_change |
35 | + | |
36 | def halfspan(lo: int, hi: int) -> float: | |
37 | return (hi - lo) / 2.0 | |
38 | - | student_share_range = (0.30, 0.40) |
38 | + | |
39 | - | work_share_range = (0.60, 0.70) |
39 | + | NEW_VISAS_RANGE = (NEW_STUDY_RANGE[0] + NEW_WORK_RANGE[0], |
40 | NEW_STUDY_RANGE[1] + NEW_WORK_RANGE[1]) | |
41 | - | expiring_students_range = (int(expiries_total_range[0] * student_share_range[0]), |
41 | + | |
42 | - | int(expiries_total_range[1] * student_share_range[1])) |
42 | + | EXPIRIES_TOTAL_RANGE = ( |
43 | - | expiring_work_range = (int(expiries_total_range[0] * work_share_range[0]), |
43 | + | NATURAL_INCREASE + NEW_VISAS_RANGE[0] + RENEWED_WORK_RANGE[0] - OBSERVED_POP_CHANGE, |
44 | - | int(expiries_total_range[1] * work_share_range[1])) |
44 | + | NATURAL_INCREASE + NEW_VISAS_RANGE[1] + RENEWED_WORK_RANGE[1] - OBSERVED_POP_CHANGE |
45 | ) | |
46 | - | def midpoint(lo, hi): return (lo + hi) / 2.0 |
46 | + | |
47 | - | def halfspan(lo, hi): return (hi - lo) / 2.0 |
47 | + | EXPIRING_STUDENTS_RANGE = ( |
48 | int(EXPIRIES_TOTAL_RANGE[0] * STUDENT_EXPIRY_SHARE_RANGE[0]), | |
49 | int(EXPIRIES_TOTAL_RANGE[1] * STUDENT_EXPIRY_SHARE_RANGE[1]), | |
50 | ) | |
51 | EXPIRING_WORK_RANGE = ( | |
52 | int(EXPIRIES_TOTAL_RANGE[0] * WORK_EXPIRY_SHARE_RANGE[0]), | |
53 | int(EXPIRIES_TOTAL_RANGE[1] * WORK_EXPIRY_SHARE_RANGE[1]), | |
54 | ) | |
55 | ||
56 | categories = [ | |
57 | "Births", | |
58 | "Deaths", | |
59 | - | values = np.array([ |
59 | + | |
60 | - | births, |
60 | + | |
61 | - | -deaths, |
61 | + | |
62 | - | midpoint(*new_visas_range), |
62 | + | |
63 | - | midpoint(*renewed_work_range), |
63 | + | |
64 | - | -midpoint(*expiring_students_range), |
64 | + | |
65 | - | -midpoint(*expiring_work_range), |
65 | + | |
66 | - | observed_pop_change |
66 | + | values = [ |
67 | - | ], dtype=float) |
67 | + | float(BIRTHS), |
68 | -float(DEATHS), | |
69 | - | yerr = np.array([ |
69 | + | midpoint(*NEW_VISAS_RANGE), |
70 | - | 0, |
70 | + | midpoint(*RENEWED_WORK_RANGE), |
71 | - | 0, |
71 | + | -midpoint(*EXPIRING_STUDENTS_RANGE), |
72 | - | halfspan(*new_visas_range), |
72 | + | -midpoint(*EXPIRING_WORK_RANGE), |
73 | - | halfspan(*renewed_work_range), |
73 | + | float(OBSERVED_POP_CHANGE), |
74 | - | halfspan(*expiring_students_range), |
74 | + | |
75 | - | halfspan(*expiring_work_range), |
75 | + | |
76 | - | 0 |
76 | + | yerr = [ |
77 | - | ], dtype=float) |
77 | + | 0.0, |
78 | 0.0, | |
79 | halfspan(*NEW_VISAS_RANGE), | |
80 | halfspan(*RENEWED_WORK_RANGE), | |
81 | halfspan(*EXPIRING_STUDENTS_RANGE), | |
82 | halfspan(*EXPIRING_WORK_RANGE), | |
83 | 0.0, | |
84 | - | plt.figure(figsize=(13,7)) |
84 | + | |
85 | ||
86 | colors = [ | |
87 | "#2ca02c", "#d62728", "#1f77b4", | |
88 | "#9467bd", "#ff7f0e", "#8c564b", "#7f7f7f" | |
89 | ] | |
90 | ||
91 | plt.figure(figsize=(13, 7)) | |
92 | bars = plt.bar(categories, values, color=colors) | |
93 | ||
94 | - | x = bar.get_x() + bar.get_width()/2 |
94 | + | |
95 | if e > 0: | |
96 | plt.errorbar(i, v, yerr=e, fmt='none', capsize=6, elinewidth=1.5, ecolor='black') | |
97 | plt.scatter([i], [v], s=40, marker='D', edgecolor='black', zorder=3) | |
98 | - | txt = f"central estimate*: {int(round(v)):,}\nrange: {int(round(lo)):,}–{int(round(hi)):,}" |
98 | + | |
99 | pad = 120_000 | |
100 | for bar, v, e in zip(bars, values, yerr): | |
101 | x = bar.get_x() + bar.get_width() / 2 | |
102 | va = 'bottom' if v >= 0 else 'top' | |
103 | if e > 0: | |
104 | lo, hi = v - e, v + e | |
105 | txt = f"central estimate*: {int(round(v)):,}\nrange: {int(round(lo)):,}-{int(round(hi)):,}" | |
106 | else: | |
107 | txt = f"{int(round(abs(v))):,}" | |
108 | y = v + (pad if v >= 0 else -pad) | |
109 | plt.text(x, y, txt, ha='center', va=va, fontsize=9) | |
110 | ||
111 | def add_group_bracket(ax, x0, x1, y, label, invert=False): | |
112 | if not invert: | |
113 | ax.plot([x0, x0, x1, x1], [y, y+40_000, y+40_000, y], lw=1.5, color='black') | |
114 | ax.text((x0+x1)/2, y+55_000, label, ha='center', va='bottom', fontsize=10) | |
115 | else: | |
116 | ax.plot([x0, x0, x1, x1], [y, y-40_000, y-40_000, y], lw=1.5, color='black') | |
117 | ax.text((x0+x1)/2, y-55_000, label, ha='center', va='top', fontsize=10) | |
118 | ||
119 | ax = plt.gca() | |
120 | ||
121 | nv_idx, rw_idx = 2, 3 | |
122 | expS_idx, expW_idx = 4, 5 | |
123 | ||
124 | y_nv = values[nv_idx] + yerr[nv_idx] | |
125 | y_rw = values[rw_idx] + yerr[rw_idx] | |
126 | y_expS = values[expS_idx] - yerr[expS_idx] | |
127 | y_expW = values[expW_idx] - yerr[expW_idx] | |
128 | ||
129 | y_group_top = max(y_nv, y_rw) + 200_000 | |
130 | sum_new_renewed = int(round(values[nv_idx] + values[rw_idx])) | |
131 | add_group_bracket(ax, nv_idx, rw_idx, y_group_top, f"New+Renewed visas (sum): {sum_new_renewed:,}") | |
132 | - | Line2D([0], [0], marker='D', color='w', markeredgecolor='black', label='Central estimate*', markersize=7) |
132 | + | |
133 | y_group_bottom = min(y_expS, y_expW) - 220_000 | |
134 | sum_expiries = int(round(abs(values[expS_idx] + values[expW_idx]))) | |
135 | add_group_bracket(ax, expS_idx, expW_idx, y_group_bottom, f"All expiries (sum): {sum_expiries:,}", invert=True) | |
136 | ||
137 | legend_elems = [ | |
138 | Line2D([0], [0], color='black', lw=1.5, label='Range (vertical line)'), | |
139 | Line2D([0], [0], marker='D', color='w', markeredgecolor='black', label='Central estimate*', markersize=7), | |
140 | - | upper = max(values + yerr) * 1.7 + 200_000 |
140 | + | |
141 | - | lower = min(values - yerr) * 1.7 - 200_000 |
141 | + | |
142 | ||
143 | plt.axhline(0, linewidth=0.9, color='black') | |
144 | plt.title("Population Components: Canada (H1 2025)\nRanges shown with central estimate*", pad=16) | |
145 | plt.ylabel("Count (people)") | |
146 | ||
147 | upper = max((v + e) for v, e in zip(values, yerr)) * 1.7 + 200_000 | |
148 | lower = min((v - e) for v, e in zip(values, yerr)) * 1.7 - 200_000 | |
149 | plt.ylim(lower, upper) | |
150 | ||
151 | plt.xticks(rotation=10) | |
152 | plt.tight_layout() | |
153 | ||
154 | plt.figtext( | |
155 | 0.5, 0.01, | |
156 | "*Estimate based on IRCC/StatCan data and a heuristic expiry split (30-40% students, 60-70% workers).", | |
157 | ha="center", fontsize=9, style="italic" | |
158 | ) | |
159 | ||
160 | plt.show() | |
161 | ||
162 |