diff --git a/Data/medium_order.csv b/Data/medium_order.csv new file mode 100644 index 0000000..143ba2a --- /dev/null +++ b/Data/medium_order.csv @@ -0,0 +1,19 @@ +Participant,Rank1,Rank2,Rank3 +P1,VR,Chat,Video +P2,Chat,Video,VR +P3,VR,Chat,Video +P4,VR,Video,Chat +P5,VR,Chat,Video +P6,Chat,Video,VR +P7,Chat,Video,VR +P8,Chat,Video,VR +P9,Chat,VR,Video +P10,Chat,VR,Video +P11,Chat,VR,Video +P12,Chat,VR,Video +P13,Video,VR,Chat +P14,Chat,Video,VR +P15,VR,Video,Chat +P16,Chat,Video,VR +P17,VR,Video,Chat +P18,Chat,Video,VR diff --git a/Data/plots_questionnaires/Q14_medium_ranking.png b/Data/plots_questionnaires/Q14_medium_ranking.png new file mode 100644 index 0000000..1ef5f6c Binary files /dev/null and b/Data/plots_questionnaires/Q14_medium_ranking.png differ diff --git a/README.md b/README.md index ae95215..36892ae 100644 --- a/README.md +++ b/README.md @@ -395,6 +395,12 @@ Four-panel dashboard comparing reading and tutoring phases across IMI subscales, VR-specific panel comparing social presence, cybersickness, and Godspeed across mediums. VR achieves the highest social presence (M=3.01 vs Chat M=2.10), moderate cybersickness symptoms, and Godspeed impressions comparable to the other mediums. The elevated social presence in VR without a corresponding increase in Godspeed tutor impression suggests that VR enhances the sense of "being there" without necessarily changing how the tutor is perceived. +### F14 – Medium Preference Rankings (Friedman Test) + +![Medium Preference Rankings](Data/plots_questionnaires/Q14_medium_ranking.png) + +Within-subject ranking analysis: each of the 18 participants ranked the three tutoring mediums from 1 (most preferred) to 3 (least preferred). **Left panel** shows mean rank per condition with individual data points (jitter) and SEM error bars. **Right panel** shows the percentage of participants assigning each rank position to each condition. Chat received the lowest mean rank (M=1.61), indicating it was most often preferred first (50% of participants ranked it 1st). Video received the highest mean rank (M=2.33), most often placed last. A Friedman test showed no statistically significant difference across mediums (χ²(2) = 4.78, p = .092, Kendall's W = 0.13), likely due to limited power with N=18. Post-hoc Wilcoxon tests (Bonferroni-corrected α = .017) revealed a trend for Chat > Video (p = .027 raw, p = .080 adjusted) that did not survive correction. + --- ## Summary diff --git a/analysis_medium_ranking.py b/analysis_medium_ranking.py new file mode 100644 index 0000000..2ed97f3 --- /dev/null +++ b/analysis_medium_ranking.py @@ -0,0 +1,197 @@ +""" +Friedman Test on medium preference rankings. + +Data format: each row = one participant; Rank1/Rank2/Rank3 hold the +condition name (VR, Chat, Video) assigned to that rank position. + +We reshape so every participant has a numeric rank (1–3) for every +condition, then run a Friedman test and save a summary plot. +""" + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import matplotlib.patches as mpatches +import seaborn as sns +from pathlib import Path +from scipy import stats + +# ── 1. Load & reshape ───────────────────────────────────────────────────────── +df_raw = pd.read_csv("Data/medium_order.csv") + +# Build a long table: (Participant, Condition, Rank) +records = [] +for _, row in df_raw.iterrows(): + for rank_pos, col in enumerate(["Rank1", "Rank2", "Rank3"], start=1): + records.append({ + "Participant": row["Participant"], + "Condition": row[col], + "Rank": rank_pos, + }) + +df_long = pd.DataFrame(records) + +# Pivot to wide: rows = participants, columns = conditions +df_wide = df_long.pivot(index="Participant", columns="Condition", values="Rank") +df_wide = df_wide[["VR", "Chat", "Video"]] # fix column order + +print("=" * 55) +print("Reshaped ranking matrix (numeric ranks per condition)") +print("=" * 55) +print(df_wide.to_string()) +print() + +# ── 2. Descriptive statistics ───────────────────────────────────────────────── +desc = df_wide.agg(["mean", "median", "std"]).T +desc.index.name = "Condition" +desc.columns = ["Mean rank", "Median rank", "SD"] + +print("=" * 55) +print("Descriptive statistics (lower rank = more preferred)") +print("=" * 55) +print(desc.round(3).to_string()) +print() + +# ── 3. Friedman test ────────────────────────────────────────────────────────── +# scipy expects one array per group (condition), each of length n_participants +statistic, p_value = stats.friedmanchisquare( + df_wide["VR"], + df_wide["Chat"], + df_wide["Video"], +) + +n = len(df_wide) +k = 3 # number of conditions +df_friedman = k - 1 # degrees of freedom +# Kendall's W (effect size) +W = statistic / (n * (k - 1)) + +print("=" * 55) +print("Friedman test") +print("=" * 55) +print(f" N (participants) : {n}") +print(f" k (conditions) : {k}") +print(f" chi2({df_friedman}) : {statistic:.4f}") +print(f" p-value : {p_value:.4f}") +print(f" Kendall's W : {W:.4f}") +print() +if p_value < 0.05: + print(" --> Significant difference in rankings (p < .05).") +else: + print(" --> No significant difference in rankings (p >= .05).") +print() + +# ── 4. Post-hoc: Wilcoxon signed-rank tests (Bonferroni-corrected) ──────────── +from itertools import combinations + +pairs = list(combinations(["VR", "Chat", "Video"], 2)) +n_pairs = len(pairs) +alpha_corr = 0.05 / n_pairs # Bonferroni threshold + +print("=" * 55) +print(f"Post-hoc: Wilcoxon signed-rank tests") +print(f"(Bonferroni-corrected alpha = {alpha_corr:.4f})") +print("=" * 55) + +posthoc_rows = [] +for a, b in pairs: + stat_w, p_w = stats.wilcoxon(df_wide[a], df_wide[b]) + sig = "*" if p_w < alpha_corr else "" + posthoc_rows.append({ + "Pair": f"{a} vs {b}", + "W statistic": round(stat_w, 4), + "p (raw)": round(p_w, 4), + "p (x3)": round(min(p_w * n_pairs, 1.0), 4), + "Sig.": sig, + }) + +df_posthoc = pd.DataFrame(posthoc_rows).set_index("Pair") +print(df_posthoc.to_string()) +print(f"\n * Significant after Bonferroni correction (alpha = {alpha_corr:.4f})") + +# ── 5. Plot ─────────────────────────────────────────────────────────────────── +CONDITIONS = ["VR", "Chat", "Video"] +MED_COLORS = {"Chat": "#42A5F5", "Video": "#FFA726", "VR": "#66BB6A"} +RANK_COLORS = ["#4CAF50", "#FFC107", "#F44336"] # 1st, 2nd, 3rd + +sns.set_theme(style="whitegrid", font_scale=1.05) +plt.rcParams["figure.dpi"] = 150 +plt.rcParams["savefig.bbox"] = "tight" + +fig, axes = plt.subplots(1, 2, figsize=(13, 5)) + +# ── Left panel: mean rank + individual jitter ───────────────────────────────── +ax = axes[0] +x_pos = np.arange(len(CONDITIONS)) + +for i, cond in enumerate(CONDITIONS): + vals = df_wide[cond].values + jitter = np.random.default_rng(42).uniform(-0.12, 0.12, size=len(vals)) + ax.scatter(np.full(len(vals), i) + jitter, vals, + color=MED_COLORS[cond], alpha=0.55, s=40, zorder=3) + +means = [df_wide[c].mean() for c in CONDITIONS] +sems = [df_wide[c].sem() for c in CONDITIONS] + +bars = ax.bar(x_pos, means, yerr=sems, capsize=5, + color=[MED_COLORS[c] for c in CONDITIONS], + edgecolor="gray", linewidth=0.6, alpha=0.75, width=0.5, zorder=2) + +for i, (m, s) in enumerate(zip(means, sems)): + ax.text(i, m + s + 0.08, f"M={m:.2f}", ha="center", fontsize=10, fontweight="bold") + +ax.set_xticks(x_pos) +ax.set_xticklabels(CONDITIONS, fontsize=12) +ax.set_ylim(0.5, 3.8) +ax.set_yticks([1, 2, 3]) +ax.set_yticklabels(["1st\n(most preferred)", "2nd", "3rd\n(least preferred)"], fontsize=9) +ax.set_ylabel("Rank (lower = more preferred)", fontsize=11) +ax.set_title("Mean Rank per Condition\n(individual observations + SEM)", fontsize=11, fontweight="bold") + +stat_label = ( + f"Friedman: chi2({df_friedman}) = {statistic:.2f}, " + f"p = {p_value:.3f}, W = {W:.2f}" +) +ax.text(0.5, 0.03, stat_label, transform=ax.transAxes, + ha="center", va="bottom", fontsize=8.5, color="dimgray", + bbox=dict(boxstyle="round,pad=0.3", fc="white", ec="lightgray", alpha=0.8)) + +# ── Right panel: stacked rank-distribution bars ─────────────────────────────── +ax = axes[1] +n_participants = len(df_wide) +rank_counts = { + cond: [(df_wide[cond] == r).sum() / n_participants * 100 for r in [1, 2, 3]] + for cond in CONDITIONS +} + +bottoms = np.zeros(len(CONDITIONS)) +for rank_idx, (rank_label, color) in enumerate( + zip(["1st choice", "2nd choice", "3rd choice"], RANK_COLORS) +): + heights = [rank_counts[c][rank_idx] for c in CONDITIONS] + bars = ax.bar(x_pos, heights, bottom=bottoms, + color=color, edgecolor="white", linewidth=0.8, + width=0.55, label=rank_label) + for xi, (h, b) in enumerate(zip(heights, bottoms)): + if h >= 8: + ax.text(xi, b + h / 2, f"{h:.0f}%", + ha="center", va="center", fontsize=10, + fontweight="bold", color="white") + bottoms += np.array(heights) + +ax.set_xticks(x_pos) +ax.set_xticklabels(CONDITIONS, fontsize=12) +ax.set_ylim(0, 105) +ax.set_ylabel("Percentage of participants (%)", fontsize=11) +ax.set_title("Rank Distribution per Condition\n(% participants assigning each rank)", fontsize=11, fontweight="bold") +ax.legend(loc="upper right", fontsize=9, + handles=[mpatches.Patch(color=c, label=l) + for c, l in zip(RANK_COLORS, ["1st choice", "2nd choice", "3rd choice"])]) + +fig.suptitle("Medium Preference Rankings (N=18, within-subjects)", fontsize=13, fontweight="bold") +fig.tight_layout() + +out_path = Path("Data/plots_questionnaires/Q14_medium_ranking.png") +fig.savefig(out_path) +plt.close(fig) +print(f"\nPlot saved: {out_path}")