Institutional perceptions (with age inset)
Diverging stacked bars (negative / neutral / positive) for three questions: Support for integration, Programmes address racism, and Effectiveness on inclusion. On the right, a small inset shows the age distribution of respondents, concentrated in early adulthood.
Responses were grouped into three blocks: negative (ratings 1–2 or “No”), neutral (rating 3 or “Not sure / mixed”), and positive (ratings 4–5 or “Yes”). Across all three questions, the chart shows a clear pattern: roughly three quarters of students give positive evaluations, a very small share are openly negative, and around one fifth remain in the neutral band. This neutral group is especially important, because it points to mixed experiences or uncertainty about how well institutions actually respond to racism and promote inclusion.
When read together with the age inset, the figure suggests an “optimistic but still forming” outlook: a predominantly young sample (mostly 18–24 years old) expresses trust in institutional efforts, yet a non-negligible portion hesitate to endorse them fully. These ambiguous responses provide an important backdrop for the experiences of discrimination shown in the next graphs.
import matplotlib.pyplot as plt
from matplotlib.patches import Patch
import numpy as np
# --------------------------
# 1) Institutional trust data
# --------------------------
support_counts = {1: 2, 2: 4, 3: 29, 4: 33, 5: 69}
prog_counts_raw = {'No': 17, 'No, Not sure': 1, 'Not sure': 32, 'Yes': 86, 'Yes, No': 1}
effective_counts = {1: 1, 2: 4, 3: 26, 4: 51, 5: 55}
def likert_to_buckets(counts):
total = sum(counts.values())
neg = counts.get(1, 0) + counts.get(2, 0)
neu = counts.get(3, 0)
pos = counts.get(4, 0) + counts.get(5, 0)
return neg, neu, pos, total
def yns_to_buckets(counts):
neg = counts.get('No', 0)
pos = counts.get('Yes', 0)
neu = counts.get('Not sure', 0) + counts.get('Yes, No', 0) + counts.get('No, Not sure', 0)
total = neg + neu + pos
return neg, neu, pos, total
def to_pct(neg, neu, pos, total):
f = 100.0 / total if total else 0
return neg*f, neu*f, pos*f
sN, sU, sP = to_pct(*likert_to_buckets(support_counts))
pN, pU, pP = to_pct(*yns_to_buckets(prog_counts_raw))
eN, eU, eP = to_pct(*likert_to_buckets(effective_counts))
labels = [
"Support for integration",
"Programmes address racism",
"Effectiveness on inclusion"
]
rows = [(sN, sU, sP), (pN, pU, pP), (eN, eU, eP)]
# Consistent colors
NEG_COLOR = "#d73027" # negative (red)
NEU_COLOR = "#f7f7f7" # neutral (light gray)
POS_COLOR = "#2b8cbe" # positive (blue)
# --------------------------
# 2) Age distribution data
# --------------------------
ages = [
24,20,22,22,18,21,19,25,20,20,21,19,20,18,24,22,19,23,22,21,
21,21,20,20,19,18,19,21,20,22,21,20,20,20,20,20,20,19,20,20,
20,20,20,23,26,25,20,21,22,20,23,19,20,22,20,19,20,20,19,20,
19,20,21,20,20,21,20,18,21,19,20,19,20,20,23,22,21,22,20,20,
20,19,20,20,20,19,20,22,20,20,20,20,19,20,20,21,20,22,20,20,
19,20,22,21,21,23,21,20,20,20,20,22,19,19,21,22,24,19,19,22,
28,20,20,21,20,19,20,22,18,20,22,19,20
]
ages_arr = np.array(ages, dtype=int)
bins = np.arange(ages_arr.min()-0.5, ages_arr.max()+1.5, 1) # 1-year bins
counts, bin_edges = np.histogram(ages_arr, bins=bins)
centers = (bin_edges[:-1] + bin_edges[1:]) / 2.0
# Smooth histogram with a simple moving average
kernel = np.array([1, 2, 1], dtype=float)
kernel = kernel / kernel.sum()
smooth = np.convolve(counts, kernel, mode='same').astype(float)
if smooth.max() > 0:
smooth = smooth / smooth.max() # normalize 0–1
# Mode age (dominant)
unique, cts = np.unique(ages_arr, return_counts=True)
mode_age = int(unique[np.argmax(cts)]) # likely 20 or 21
# --------------------------
# Plot: main chart + inset
# --------------------------
fig, ax = plt.subplots(figsize=(12, 6))
# Diverging stacked bars
ypos = list(range(len(rows)))[::-1]
for y, (neg, neu, pos) in zip(ypos, rows):
ax.barh(y, -neg, left=0, align='center', color=NEG_COLOR, edgecolor='none')
ax.barh(y, neu, left=0, align='center', color=NEU_COLOR, edgecolor='none')
ax.barh(y, pos, left=neu, align='center', color=POS_COLOR, edgecolor='none')
ax.set_yticks(ypos)
ax.set_yticklabels(labels)
ax.set_xlim(-100, 100)
ax.set_xlabel("Share of respondents (%)")
ax.axvline(0, linewidth=1, color="#999999")
ax.set_title("Institutional perceptions")
# Segment annotations
def annotate(x_left, width, y, text):
if abs(width) < 4: return
ax.text(x_left + width/2, y, text, va='center', ha='center', fontsize=9)
for y, (neg, neu, pos) in zip(ypos, rows):
annotate(-neg, neg, y, f"{neg:.0f}%")
annotate(0, neu, y, f"{neu:.0f}%")
annotate(neu, pos, y, f"{pos:.0f}%")
# Legend
handles = [
Patch(facecolor=NEG_COLOR, edgecolor='none', label="Negative (1–2 or No)"),
Patch(facecolor=NEU_COLOR, edgecolor='none', label="Neutral (3 or Not sure / mixed)"),
Patch(facecolor=POS_COLOR, edgecolor='none', label="Positive (4–5 or Yes)")
]
ax.legend(handles=handles, loc="upper center", ncol=3, bbox_to_anchor=(0.5, 1.12), frameon=False)
# --------------------------
# Inset: small age distribution on the right
# --------------------------
mini = ax.inset_axes([0.15, 0.15, 0.22, 0.55]) # x0, y0, width, height
mini.set_facecolor("white")
mini.plot(centers, smooth, color=POS_COLOR, lw=2)
# Only one x tick at dominant age
mini.set_xticks([mode_age])
mini.set_xticklabels([str(mode_age)])
# No y ticks; vertical label
mini.set_yticks([])
mini.set_ylabel("age distribution", rotation=90)
# Clean look
for spine in ["top", "right"]:
mini.spines[spine].set_visible(False)
mini.spines["bottom"].set_alpha(0.4)
mini.spines["left"].set_alpha(0.4)
mini.set_title("Age distribution", pad=6, fontsize=10)
plt.tight_layout()
plt.show()
