BU 350 — Final Project: Data Visualization¶

Digital Habits vs. Mental Health Dataset¶

Hali Sarah Parsons | DSC 350-T301 Practical Python Data Wrangling | May 2026


This notebook builds on the midterm project dataset — digital_habits_vs_mental_health.xlsx — which was previously cleaned, deduplicated, and transformed. Any additional transformations needed for visualization are applied here before the charts are generated.

Dataset: Digital Habits vs. Mental Health (Kaggle)
Original source: Abhishekdave9. (2024). Digital habits vs. mental health dataset [Data set]. Kaggle. https://www.kaggle.com/datasets/abhishekdave9/digital-habits-vs-mental-health-dataset
Rows (post-cleaning): 98,135 | Columns: 8

In [1]:
# =============================================================
# Title:       BU 350 Final Project — Data Visualization
# Author:      Hali Sarah Parsons
# Date:        May 2026
# Description: Builds on midterm transformations to produce five
#              visualizations (4 chart types + histogram with KDE)
#              exploring digital habits and mental health patterns.
# Dataset:     digital_habits_vs_mental_health.xlsx (Kaggle)
# =============================================================

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import seaborn as sns
from scipy import stats

%matplotlib inline
plt.rcParams['figure.dpi'] = 120
plt.rcParams['font.family'] = 'sans-serif'

print("Libraries loaded.")
Libraries loaded.

Step 1 — Load Dataset and Reapply Midterm Transformations¶

The midterm notebook applied five transformations to this dataset:

  1. Column renaming to human-readable labels
  2. Duplicate row removal (1,865 rows removed)
  3. IQR-based outlier flagging on screen time
  4. Sleep quality categorization from sleep duration
  5. Decimal precision standardization on float columns

These are reapplied here to reproduce the clean, analysis-ready state before adding any new transformations.

In [2]:
# =============================================================
# Load the raw dataset and reapply all five midterm transformations
# in sequence so this notebook is self-contained and reproducible.
# =============================================================

df = pd.read_excel('digital_habits_vs_mental_health.xlsx')

# --- Transformation 1: Rename columns to readable labels ---
df.rename(columns={
    'screen_time_hours':           'Daily Screen Time (hrs)',
    'social_media_platforms_used': 'Platforms Used',
    'hours_on_TikTok':             'TikTok Usage (hrs)',
    'sleep_hours':                 'Sleep Duration (hrs)',
    'stress_level':                'Stress Level (1-10)',
    'mood_score':                  'Mood Score (1-10)'
}, inplace=True)

# --- Transformation 2: Remove duplicate rows ---
df.drop_duplicates(inplace=True)
df.reset_index(drop=True, inplace=True)

# --- Transformation 3: Flag screen time outliers using IQR ---
col = 'Daily Screen Time (hrs)'
Q1 = df[col].quantile(0.25)
Q3 = df[col].quantile(0.75)
IQR = Q3 - Q1
df['Screen Time Outlier'] = (df[col] < Q1 - 1.5 * IQR) | (df[col] > Q3 + 1.5 * IQR)

# --- Transformation 4: Categorize sleep duration ---
def categorize_sleep(hours):
    if hours < 5:   return 'Severely Insufficient'
    elif hours < 6: return 'Insufficient'
    elif hours < 7: return 'Below Recommended'
    elif hours <= 9: return 'Recommended'
    else:           return 'Excessive'

df['Sleep Quality Category'] = df['Sleep Duration (hrs)'].apply(categorize_sleep)

# --- Transformation 5: Standardize decimal precision ---
for c in ['Daily Screen Time (hrs)', 'TikTok Usage (hrs)', 'Sleep Duration (hrs)']:
    df[c] = df[c].round(1)

print(f"Dataset shape after midterm transformations: {df.shape}")
print(f"Columns: {list(df.columns)}")
df.head()
Dataset shape after midterm transformations: (98135, 8)
Columns: ['Daily Screen Time (hrs)', 'Platforms Used', 'TikTok Usage (hrs)', 'Sleep Duration (hrs)', 'Stress Level (1-10)', 'Mood Score (1-10)', 'Screen Time Outlier', 'Sleep Quality Category']
Out[2]:
Daily Screen Time (hrs) Platforms Used TikTok Usage (hrs) Sleep Duration (hrs) Stress Level (1-10) Mood Score (1-10) Screen Time Outlier Sleep Quality Category
0 12.0 4 7.1 10.0 7 9 True Excessive
1 11.3 1 6.5 10.0 9 10 False Excessive
2 10.8 2 6.4 10.0 7 10 False Excessive
3 10.8 5 6.2 10.0 7 10 False Excessive
4 11.3 4 6.1 10.0 5 8 False Excessive

Step 2 — Additional Transformations for Visualization¶

Two additional transformations are applied here to support the visualization layer:

Transformation 6 — Stress Category (ordinal binning): The 10-point stress scale is binned into three interpretable tiers (Low: 1–3, Moderate: 4–7, High: 8–10). This creates a cleaner grouping variable for bar and box plot comparisons without losing the ordinal meaning of the scale.

Transformation 7 — Screen Time Tier (labeled bins): Daily screen time is binned into four named ranges that correspond to meaningful usage levels (Light, Moderate, Heavy, Excessive). This supports proportional and grouped comparisons across the dataset.

In [3]:
# =============================================================
# Transformation 6: Bin stress level into three interpretable tiers
# Low (1-3), Moderate (4-7), High (8-10) for grouped comparisons
# =============================================================

stress_bins   = [0, 3, 7, 10]
stress_labels = ['Low (1-3)', 'Moderate (4-7)', 'High (8-10)']

df['Stress Category'] = pd.cut(
    df['Stress Level (1-10)'],
    bins=stress_bins,
    labels=stress_labels,
    right=True
)

print("Stress Category distribution:")
print(df['Stress Category'].value_counts().sort_index())
Stress Category distribution:
Stress Category
Low (1-3)          9597
Moderate (4-7)    61809
High (8-10)       26729
Name: count, dtype: int64
In [4]:
# =============================================================
# Transformation 7: Bin daily screen time into named usage tiers
# Provides a labeled categorical variable for proportional charts
# =============================================================

screen_bins   = [0, 3, 6, 9, 13]
screen_labels = ['Light (1-3 hrs)', 'Moderate (3-6 hrs)', 'Heavy (6-9 hrs)', 'Excessive (9+ hrs)']

df['Screen Time Tier'] = pd.cut(
    df['Daily Screen Time (hrs)'],
    bins=screen_bins,
    labels=screen_labels,
    right=True
)

print("Screen Time Tier distribution:")
print(df['Screen Time Tier'].value_counts().sort_index())
print(f"\nFinal dataset shape: {df.shape}")
df.head()
Screen Time Tier distribution:
Screen Time Tier
Light (1-3 hrs)        6745
Moderate (3-6 hrs)    43200
Heavy (6-9 hrs)       41702
Excessive (9+ hrs)     6488
Name: count, dtype: int64

Final dataset shape: (98135, 10)
Out[4]:
Daily Screen Time (hrs) Platforms Used TikTok Usage (hrs) Sleep Duration (hrs) Stress Level (1-10) Mood Score (1-10) Screen Time Outlier Sleep Quality Category Stress Category Screen Time Tier
0 12.0 4 7.1 10.0 7 9 True Excessive Moderate (4-7) Excessive (9+ hrs)
1 11.3 1 6.5 10.0 9 10 False Excessive High (8-10) Excessive (9+ hrs)
2 10.8 2 6.4 10.0 7 10 False Excessive Moderate (4-7) Excessive (9+ hrs)
3 10.8 5 6.2 10.0 7 10 False Excessive Moderate (4-7) Excessive (9+ hrs)
4 11.3 4 6.1 10.0 5 8 False Excessive Moderate (4-7) Excessive (9+ hrs)

Visualization 1 — Bar Chart: Mean Mood Score by Stress Category¶

What this chart shows: Average mood score across the three stress tiers (Low, Moderate, High). Each bar represents the mean mood score for respondents in that stress group, with a 95% confidence interval shown as an error bar. The chart tests whether higher stress consistently corresponds to lower reported mood across the full dataset.

In [5]:
# =============================================================
# Visualization 1: Seaborn Bar Chart
# Mean Mood Score by Stress Category (Low / Moderate / High)
# Error bars show 95% confidence interval around each group mean.
# Color maps intuitively: green for low stress, red for high stress.
# =============================================================

palette_stress = {'Low (1-3)': '#27AE60', 'Moderate (4-7)': '#E67E22', 'High (8-10)': '#C0392B'}

fig, ax = plt.subplots(figsize=(9, 6))

sns.barplot(
    data=df,
    x='Stress Category',
    y='Mood Score (1-10)',
    palette=palette_stress,
    order=stress_labels,
    errorbar='ci',
    capsize=0.06,
    ax=ax
)

# Annotate mean value above each bar
for patch, label in zip(ax.patches, stress_labels):
    mean_val = df[df['Stress Category'] == label]['Mood Score (1-10)'].mean()
    ax.text(
        patch.get_x() + patch.get_width() / 2,
        patch.get_height() + 0.05,
        f'{mean_val:.2f}',
        ha='center', va='bottom',
        fontsize=12, fontweight='bold'
    )

ax.set_title("Visualization 1 — Mean Mood Score by Stress Category", fontsize=14, fontweight='bold')
ax.set_xlabel("Stress Category", fontsize=12)
ax.set_ylabel("Mean Mood Score (1-10)", fontsize=12)
ax.set_ylim(0, 11)
ax.yaxis.grid(True, linestyle='--', alpha=0.5)
ax.set_axisbelow(True)
sns.despine()
plt.tight_layout()
plt.savefig('viz1_bar.png', dpi=120, bbox_inches='tight')
plt.show()
print("Visualization 1 rendered.")
/tmp/ipykernel_562/1555461023.py:12: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.barplot(
No description has been provided for this image
Visualization 1 rendered.

Visualization 2 — Box Plot: Sleep Duration by Sleep Quality Category¶

What this chart shows: The distribution of sleep hours across the five sleep quality categories defined in the midterm (Severely Insufficient through Excessive). Each box displays the median, interquartile range, and whiskers extending to 1.5 × IQR — the same outlier threshold applied in Transformation 3. This chart confirms that the categorization function produced clean, non-overlapping bins and surfaces the spread within each tier.

In [6]:
# =============================================================
# Visualization 2: Seaborn Box Plot
# Sleep Duration distribution across Sleep Quality Categories.
# The IQR-based whiskers match the outlier rule from the midterm.
# Ordered from least to most sleep for logical left-to-right reading.
# =============================================================

sleep_order = ['Severely Insufficient', 'Insufficient', 'Below Recommended', 'Recommended', 'Excessive']
palette_sleep = sns.color_palette("Blues", n_colors=5)

fig, ax = plt.subplots(figsize=(11, 6))

sns.boxplot(
    data=df,
    x='Sleep Quality Category',
    y='Sleep Duration (hrs)',
    order=sleep_order,
    palette=palette_sleep,
    linewidth=1.4,
    flierprops={'marker': 'o', 'markersize': 2, 'alpha': 0.3},
    ax=ax
)

ax.set_title("Visualization 2 — Sleep Duration by Sleep Quality Category", fontsize=14, fontweight='bold')
ax.set_xlabel("Sleep Quality Category", fontsize=12)
ax.set_ylabel("Sleep Duration (hours/night)", fontsize=12)
ax.tick_params(axis='x', labelsize=10)
ax.yaxis.grid(True, linestyle='--', alpha=0.5)
ax.set_axisbelow(True)
sns.despine()
plt.tight_layout()
plt.savefig('viz2_boxplot.png', dpi=120, bbox_inches='tight')
plt.show()
print("Visualization 2 rendered.")
/tmp/ipykernel_562/1363800933.py:13: FutureWarning: 

Passing `palette` without assigning `hue` is deprecated and will be removed in v0.14.0. Assign the `x` variable to `hue` and set `legend=False` for the same effect.

  sns.boxplot(
No description has been provided for this image
Visualization 2 rendered.

Visualization 3 — Scatter Plot: Screen Time vs. Mood Score (by Stress Category)¶

What this chart shows: Each point represents one respondent, plotted by their daily screen time (x-axis) and mood score (y-axis). Points are colored by stress category so the viewer can see whether the stress-mood relationship holds consistently across different screen time levels. A regression line per group shows the directional trend within each stress tier.

In [7]:
# =============================================================
# Visualization 3: Seaborn Scatter Plot with Regression Lines
# Screen time vs. Mood Score, colored by Stress Category.
# alpha=0.15 prevents overplotting at 98,000+ points.
# A lmplot-style overlay shows linear trend per stress group.
# =============================================================

palette_stress_scatter = {
    'Low (1-3)':    '#27AE60',
    'Moderate (4-7)': '#E67E22',
    'High (8-10)':  '#C0392B'
}

fig, ax = plt.subplots(figsize=(11, 7))

for category, color in palette_stress_scatter.items():
    subset = df[df['Stress Category'] == category]
    ax.scatter(
        subset['Daily Screen Time (hrs)'],
        subset['Mood Score (1-10)'],
        alpha=0.08,         # heavy transparency handles 98k+ point overplotting
        s=6,
        color=color,
        label=f'Stress: {category}'
    )
    # Add a linear regression line per group to show directional trend
    m, b, r, p, se = stats.linregress(subset['Daily Screen Time (hrs)'], subset['Mood Score (1-10)'])
    x_line = np.linspace(subset['Daily Screen Time (hrs)'].min(), subset['Daily Screen Time (hrs)'].max(), 100)
    ax.plot(x_line, m * x_line + b, color=color, linewidth=2.2, alpha=0.9)

ax.set_title(
    "Visualization 3 — Daily Screen Time vs. Mood Score by Stress Category",
    fontsize=14, fontweight='bold'
)
ax.set_xlabel("Daily Screen Time (hours)", fontsize=12)
ax.set_ylabel("Mood Score (1-10)", fontsize=12)
ax.legend(title="Stress Category", fontsize=10, title_fontsize=10)
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
plt.savefig('viz3_scatter.png', dpi=120, bbox_inches='tight')
plt.show()
print("Visualization 3 rendered.")
No description has been provided for this image
Visualization 3 rendered.

Visualization 4 — Heatmap: Correlation Matrix Across All Numeric Variables¶

What this chart shows: Pairwise Pearson correlations between all numeric columns in the cleaned dataset. The lower triangle is displayed (upper triangle is a mirror and adds no information). Color maps from blue (negative correlation) to red (positive correlation), anchored at white for zero. This chart answers the broadest possible question about the dataset: which variables move together, and how strongly?

In [8]:
# =============================================================
# Visualization 4: Seaborn Heatmap — Correlation Matrix
# Lower triangle only (upper is redundant). Diagonal masked.
# coolwarm diverging palette: blue = negative, red = positive.
# Annotated with 2-decimal correlation values for exact reading.
# =============================================================

numeric_cols = [
    'Daily Screen Time (hrs)', 'Platforms Used', 'TikTok Usage (hrs)',
    'Sleep Duration (hrs)', 'Stress Level (1-10)', 'Mood Score (1-10)'
]

corr = df[numeric_cols].corr()

# Mask: True = hidden. triu masks upper triangle including diagonal.
mask = np.triu(np.ones_like(corr, dtype=bool))

display_labels = ['Screen Time', 'Platforms Used', 'TikTok Hours',
                  'Sleep Duration', 'Stress Level', 'Mood Score']

fig, ax = plt.subplots(figsize=(9, 7))

sns.heatmap(
    corr,
    mask=mask,
    annot=True,
    fmt='.2f',
    cmap='coolwarm',
    center=0,
    vmin=-1, vmax=1,
    linewidths=0.5,
    linecolor='white',
    xticklabels=display_labels,
    yticklabels=display_labels,
    ax=ax
)

ax.set_title(
    "Visualization 4 — Pearson Correlation Matrix: Digital Habits & Mental Health",
    fontsize=13, fontweight='bold', pad=15
)
plt.xticks(rotation=30, ha='right', fontsize=10)
plt.yticks(rotation=0, fontsize=10)
plt.tight_layout()
plt.savefig('viz4_heatmap.png', dpi=120, bbox_inches='tight')
plt.show()
print("Visualization 4 rendered.")
No description has been provided for this image
Visualization 4 rendered.

Visualization 5 — Histogram with Distribution Curve: Daily Screen Time¶

What this chart shows: The full distribution of daily screen time across all 98,135 respondents, with a fitted normal distribution curve overlaid. The mean and median reference lines reveal whether the distribution is symmetric or skewed. The IQR outlier bounds from Transformation 3 are marked to show where flagged records fall relative to the bulk of the data.

In [9]:
# =============================================================
# Visualization 5: Histogram + Normal Distribution Curve Overlay
# Required chart for the final project submission.
# scipy.stats.norm.fit() estimates mu and sigma from the actual data.
# IQR bounds from Transformation 3 are marked for reference.
# density=True aligns the histogram y-axis with the PDF curve scale.
# =============================================================

screen = df['Daily Screen Time (hrs)'].dropna()

# Fit a normal distribution to the observed screen time data
mu, std = stats.norm.fit(screen)

# Recalculate IQR bounds for annotation
Q1_s = screen.quantile(0.25)
Q3_s = screen.quantile(0.75)
IQR_s = Q3_s - Q1_s
lower_iqr = Q1_s - 1.5 * IQR_s
upper_iqr = Q3_s + 1.5 * IQR_s

fig, ax = plt.subplots(figsize=(11, 6))

# Histogram bars — density=True scales to probability density
ax.hist(
    screen,
    bins=24,
    density=True,
    color='#4C9A8A',
    edgecolor='white',
    linewidth=0.6,
    alpha=0.78,
    label='Observed Distribution'
)

# Fitted normal curve across the observed range
x_range = np.linspace(screen.min(), screen.max(), 400)
pdf_fitted = stats.norm.pdf(x_range, mu, std)
ax.plot(x_range, pdf_fitted, color='#2B6B5E', linewidth=2.8,
        label=f'Fitted Normal Curve  μ={mu:.2f}, σ={std:.2f}')

# Mean and median reference lines
ax.axvline(mu, color='#C0392B', linewidth=1.8, linestyle='--',
           label=f'Mean = {mu:.2f} hrs')
ax.axvline(screen.median(), color='#E67E22', linewidth=1.8, linestyle=':',
           label=f'Median = {screen.median():.2f} hrs')

# IQR outlier bounds — show where the midterm flagging threshold sits
ax.axvline(lower_iqr, color='#7F8C8D', linewidth=1.2, linestyle='-.',
           alpha=0.7, label=f'IQR Lower = {lower_iqr:.1f} hrs')
ax.axvline(upper_iqr, color='#7F8C8D', linewidth=1.2, linestyle='-.',
           alpha=0.7, label=f'IQR Upper = {upper_iqr:.1f} hrs')

ax.set_title(
    "Visualization 5 — Distribution of Daily Screen Time with Fitted Normal Curve\n"
    "(IQR outlier bounds from midterm Transformation 3 marked in grey)",
    fontsize=13, fontweight='bold'
)
ax.set_xlabel("Daily Screen Time (hours/day)", fontsize=12)
ax.set_ylabel("Probability Density", fontsize=12)
ax.legend(fontsize=10, loc='upper right')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
plt.tight_layout()
plt.savefig('viz5_hist_kde.png', dpi=120, bbox_inches='tight')
plt.show()
print(f"Visualization 5 rendered.  μ={mu:.2f}, σ={std:.2f}")
print(f"IQR outlier bounds: [{lower_iqr:.2f}, {upper_iqr:.2f}]")
No description has been provided for this image
Visualization 5 rendered.  μ=6.02, σ=1.99
IQR outlier bounds: [0.65, 11.45]

Final Paper¶

The dataset at the center of this project — Digital Habits vs. Mental Health — came from Kaggle and held 100,000 rows tracking six variables: daily screen time, platforms used, TikTok hours, sleep duration, stress level, and mood score. The midterm established the foundation. Five transformations cleaned and structured the data: columns were renamed for readability, 1,865 duplicate rows were removed, screen time outliers were flagged using the IQR method, sleep duration was categorized into quality tiers, and float columns were standardized to one decimal place. The final project added two more transformations — stress binning and screen time tiering — to support grouped visual comparisons.

The visualizations surfaced patterns the raw numbers alone could not communicate. The bar chart confirmed that mood scores drop steadily across stress tiers, which the descriptive statistics hinted at but did not make immediate. The box plot validated the midterm's sleep categorization function by showing clean, non-overlapping distributions in each tier — a useful quality check that transformed output deserved. The scatter plot was the most visually complex chart and the most informative. Plotting 98,000 points at low opacity turned what could have been a wall of noise into a readable density map, and the per-group regression lines showed that the inverse relationship between screen time and mood holds across all three stress tiers. The correlation heatmap answered the broadest question — which variables move together — and the answer was mostly: not strongly. The near-zero correlations between most pairs were themselves a finding worth noting. The histogram with the fitted normal curve showed that screen time is approximately normally distributed with the IQR outlier bounds sitting well inside the observed range, which explained why the midterm flagged very few outliers despite the wide apparent spread.

What I learned most from this course is that data wrangling and data visualization are the same project approached from two directions. Wrangling asks whether the data is structurally sound enough to analyze. Visualization asks whether the analysis is communicable enough to be useful. Neither is complete without the other. A clean dataset with no visualization leaves patterns buried in rows. A visualization built on uncleaned data makes patterns up.

What took the longest for me to wrap my mind around was the IQR method — specifically why a statistical rule that feels mechanical can still require human judgment to apply. Flagging outliers is straightforward in code. Deciding what to do with them requires understanding the domain. That distinction, between what Python can calculate and what a person has to interpret, runs through every chapter of this course and every decision in this project.


References¶

Abhishekdave9. (2024). Digital habits vs. mental health dataset [Data set]. Kaggle. https://www.kaggle.com/datasets/abhishekdave9/digital-habits-vs-mental-health-dataset

McGregor, Susan E. (2022). Practical Python data wrangling and data quality. O'Reilly Media.