Skip to content

Commit 58d1640

Browse files
committed
refactor(user-testing): standardize analysis visualizations with shared statistical utilities
Extract common statistical functions to stats_utils module and normalize user type naming across analysis pipeline. Enhance box plot visualizations with 95% confidence intervals using t-Student distribution, consistent color schemes, and comprehensive legends. Apply sentence case formatting to all labels.
1 parent 6f39edc commit 58d1640

File tree

11 files changed

+266
-119
lines changed

11 files changed

+266
-119
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
Common statistical calculations used across different analysis modules
3+
"""
4+
5+
import numpy as np
6+
from scipy import stats
7+
from typing import Tuple
8+
9+
10+
def get_boxplot_legend_text() -> str:
11+
"""Get standardized box plot elements description text.
12+
13+
Returns:
14+
Formatted text explaining box plot elements with line wrapping
15+
"""
16+
return (
17+
"Box plot elements:\n"
18+
"• Box edges: 25th (Q1) and 75th (Q3)\n"
19+
" percentiles (interquartile range, IQR)\n"
20+
"• Blue line: Median (50th percentile)\n"
21+
"• Red diamond: Mean\n"
22+
"• Green bars: 95% confidence interval\n"
23+
" for the mean (t-Student)\n"
24+
"• Whiskers: Extend to the most extreme\n"
25+
" data point within 1.5×IQR from box edges\n"
26+
"• Circles: Outliers (values beyond\n"
27+
" 1.5×IQR from box edges)"
28+
)
29+
30+
31+
def calculate_mean_confidence_interval(data: np.ndarray, confidence: float = 0.95) -> Tuple[float, float, float]:
32+
"""Calculate mean and its confidence interval using t-Student distribution.
33+
34+
Args:
35+
data: Array of numeric values
36+
confidence: Confidence level (default 0.95 for 95% CI)
37+
38+
Returns:
39+
Tuple of (mean, ci_lower, ci_upper)
40+
"""
41+
n = len(data)
42+
43+
if n == 0:
44+
return (float('nan'), float('nan'), float('nan'))
45+
46+
mean_val = float(np.mean(data))
47+
48+
if n == 1:
49+
# Single observation: no confidence interval
50+
return (mean_val, mean_val, mean_val)
51+
52+
# Calculate 95% CI using t-Student distribution
53+
ci = stats.t.interval(confidence, n - 1, loc=mean_val, scale=stats.sem(data))
54+
ci_lower = float(ci[0])
55+
ci_upper = float(ci[1])
56+
57+
return (mean_val, ci_lower, ci_upper)

user_testing/analysis_software/sus_calculator.py

Lines changed: 93 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,16 @@
1313
import numpy as np
1414
import pandas as pd
1515

16+
from stats_utils import calculate_mean_confidence_interval, get_boxplot_legend_text
17+
1618

1719
class SUSCalculator:
20+
# Map normalized user types to directory names
21+
USER_TYPE_TO_DIR = {
22+
'end_user': 'endusers',
23+
'technician': 'technicians'
24+
}
25+
1826
def __init__(self, results_dir: str = "../results"):
1927
self.results_dir = Path(results_dir)
2028
self.output_dir = self.results_dir / "aggregated_analysis" / "sus"
@@ -70,26 +78,32 @@ def calculate_sus_score(self, ratings: List[int]) -> float:
7078

7179

7280
def process_user_type(self, user_type: str) -> List[Dict]:
73-
"""Process all SUS files for a specific user type."""
74-
sus_dir = self.results_dir / user_type / "sus"
81+
"""Process all SUS files for a specific user type.
82+
83+
Args:
84+
user_type: Normalized user type ('end_user' or 'technician')
85+
"""
86+
# Map normalized user type to directory name
87+
dir_name = self.USER_TYPE_TO_DIR.get(user_type, user_type)
88+
sus_dir = self.results_dir / dir_name / "sus"
7589
results = []
76-
90+
7791
if not sus_dir.exists():
7892
print(f"Warning: SUS directory not found: {sus_dir}")
7993
return results
80-
94+
8195
for sus_file in sus_dir.glob("*_sus.md"):
8296
parsed = self.parse_sus_file(sus_file)
8397
if parsed:
8498
score = self.calculate_sus_score(parsed['ratings'])
8599
results.append({
86100
'participant_id': parsed['participant_id'],
87-
'user_type': user_type,
101+
'user_type': user_type, # Use normalized name in output
88102
'ratings': parsed['ratings'],
89103
'sus_score': score,
90104
'file_path': parsed['file_path']
91105
})
92-
106+
93107
return results
94108

95109
def generate_aggregated_stats(self, scores_by_type: Dict[str, List[float]]) -> Dict:
@@ -122,33 +136,76 @@ def create_visualizations(self, all_results: List[Dict]):
122136
df = pd.DataFrame(all_results)
123137

124138
# Create figure with subplots (1x2): SUS boxplot + Summary
125-
# Use 16:9 aspect ratio to better utilize horizontal space
126-
fig = plt.figure(figsize=(12, 6.75))
127-
gs = fig.add_gridspec(1, 2, width_ratios=[1.5, 1], wspace=0.3, top=0.95, bottom=0.22)
139+
# Compact layout with reduced dimensions
140+
fig = plt.figure(figsize=(10, 5.5))
141+
gs = fig.add_gridspec(1, 2, width_ratios=[1.5, 1], wspace=0.3, top=0.95, bottom=0.10)
128142
ax1 = fig.add_subplot(gs[0, 0])
129143
ax2 = fig.add_subplot(gs[0, 1])
130144

131145
# 1. Box plot by user type
132146
user_types = list(df['user_type'].unique())
133147
box_data = [df[df['user_type'] == ut]['sus_score'].values for ut in user_types]
134-
box_plot = ax1.boxplot(box_data, tick_labels=user_types, patch_artist=True,
135-
medianprops=dict(color='darkblue', linewidth=2.5))
148+
149+
# Map user types to proper labels
150+
user_type_labels = []
151+
for ut in user_types:
152+
if 'enduser' in ut.lower():
153+
user_type_labels.append('End user')
154+
elif 'technician' in ut.lower():
155+
user_type_labels.append('Technician')
156+
else:
157+
user_type_labels.append(ut.replace('_', ' ').capitalize())
158+
159+
box_plot = ax1.boxplot(box_data, tick_labels=user_type_labels, patch_artist=True,
160+
showmeans=True, meanline=False,
161+
medianprops=dict(color='darkblue', linewidth=2.5),
162+
meanprops=dict(marker='D', markerfacecolor='red',
163+
markeredgecolor='black', markersize=6))
136164
ax1.set_title('SUS score distribution by user type')
137165
ax1.set_ylabel('SUS score')
138166
ax1.axhline(y=68, color='orange', linestyle='--', alpha=0.7, label='Average threshold')
139167
ax1.axhline(y=80.3, color='green', linestyle='--', alpha=0.7, label='Excellent threshold')
140-
# Add median to legend
168+
169+
# Calculate 95% CI for mean using t-Student distribution
170+
means = []
171+
ci_lowers = []
172+
ci_uppers = []
173+
for data in box_data:
174+
mean_val, ci_lower, ci_upper = calculate_mean_confidence_interval(data)
175+
means.append(mean_val)
176+
ci_lowers.append(ci_lower)
177+
ci_uppers.append(ci_upper)
178+
179+
# Add confidence interval error bars
180+
x_positions = np.arange(1, len(means) + 1)
181+
ci_errors = [np.array(means) - np.array(ci_lowers),
182+
np.array(ci_uppers) - np.array(means)]
183+
ax1.errorbar(x_positions, means, yerr=ci_errors,
184+
fmt='none', ecolor='darkgreen', capsize=5, capthick=2,
185+
linewidth=2, alpha=0.8, label='95% CI')
186+
187+
# Updated legend with all elements
141188
ax1.plot([], [], color='darkblue', linewidth=2.5, label='Median')
142-
ax1.legend()
189+
ax1.plot([], [], marker='D', color='red', linestyle='None',
190+
markeredgecolor='black', markersize=6, label='Mean')
191+
ax1.plot([], [], marker='o', markerfacecolor='none', markeredgecolor='black',
192+
linestyle='None', markersize=5, label='Outliers')
193+
# Extend Y-axis to create space for legend at bottom-right
194+
ax1.set_ylim(bottom=50)
195+
# Position legend in lower right with padding
196+
ax1.legend(loc='lower right', frameon=True, fontsize=9)
143197
ax1.grid(True, alpha=0.3)
144-
145-
# Color boxes
146-
base_colors = ['lightblue', 'lightcoral', 'lightgreen', 'wheat', 'plum', 'khaki']
147-
colors = (base_colors * ((len(user_types) // len(base_colors)) + 1))[: len(user_types)]
148-
for patch, color in zip(box_plot['boxes'], colors):
198+
199+
# Color boxes (consistent with task duration plots)
200+
user_type_colors = {
201+
'end_user': 'lightblue',
202+
'technician': 'lightcoral'
203+
}
204+
for patch, user_type in zip(box_plot['boxes'], user_types):
205+
color = user_type_colors.get(user_type, 'lightgray')
149206
patch.set_facecolor(color)
150207

151-
# 2. Summary statistics table (text-based)
208+
# 2. Summary statistics table (text-based) with box plot elements below
152209
ax2.axis('off')
153210
stats_lines = ["SUS statistics summary", ""]
154211
for user_type in user_types:
@@ -159,29 +216,31 @@ def create_visualizations(self, all_results: List[Dict]):
159216
min_val = user_data.min()
160217
max_val = user_data.max()
161218

162-
stats_lines.append(f"{user_type.replace('_', ' ').title()}:")
219+
# Map user types to proper labels
220+
if 'enduser' in user_type.lower():
221+
user_type_label = 'End user'
222+
elif 'technician' in user_type.lower():
223+
user_type_label = 'Technician'
224+
else:
225+
user_type_label = user_type.replace('_', ' ').capitalize()
226+
stats_lines.append(f"{user_type_label}:")
163227
stats_lines.append(f" Count: {len(user_data)}")
164228
stats_lines.append(f" Mean SUS: {user_data.mean():.1f}")
165229
stats_lines.append(f" Median: {median:.1f}")
166230
stats_lines.append(f" Q1 (25th percentile): {q1:.1f}")
167231
stats_lines.append(f" Q3 (75th percentile): {q3:.1f}")
168232
stats_lines.append(f" Range: {min_val:.1f}-{max_val:.1f}")
169233
stats_lines.append("")
234+
235+
# Add box plot legend directly after statistics
236+
stats_lines.append("")
237+
legend_text = get_boxplot_legend_text()
238+
stats_lines.extend(legend_text.split("\n"))
239+
170240
stats_text = "\n".join(stats_lines)
171241
ax2.text(0.05, 0.95, stats_text, transform=ax2.transAxes, fontsize=10,
172242
verticalalignment='top', fontfamily='monospace')
173243

174-
# Add box plot legend explaining elements (positioned below the plot)
175-
legend_text = (
176-
"Box plot elements:\n"
177-
"• Box edges: 25th (Q1) and 75th (Q3) percentiles (interquartile range, IQR)\n"
178-
"• Blue line: Median (50th percentile)\n"
179-
"• Whiskers: Extend to the most extreme data point within 1.5×IQR from box edges\n"
180-
"• Circles: Outliers (values beyond 1.5×IQR from box edges)"
181-
)
182-
fig.text(0.5, 0.04, legend_text, ha='center', fontsize=10,
183-
verticalalignment='bottom', multialignment='left')
184-
185244
# Save visualization
186245
output_file = self.output_dir / "sus_visualizations.png"
187246
plt.savefig(output_file, dpi=300, bbox_inches='tight')
@@ -191,8 +250,9 @@ def create_visualizations(self, all_results: List[Dict]):
191250
def generate_reports(self) -> Dict:
192251
"""Generate comprehensive SUS analysis reports."""
193252
all_results = []
194-
195-
user_types = ['endusers', 'technicians']
253+
254+
# Use normalized user type names
255+
user_types = ['end_user', 'technician']
196256

197257
for user_type in user_types:
198258
results = self.process_user_type(user_type)

0 commit comments

Comments
 (0)