dahutapea's picture
Push EDA fixes
3a7651b
Raw
History Blame Contribute Delete
20.5 kB
import os
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from utils.paths import ARTIFACTS
POS_COLOR = "#2e7d32"
NEG_COLOR = "#c62828"
ACCENT = "#1565c0"
@st.cache_data
def load_data():
return pd.read_csv(os.path.join(ARTIFACTS, "dashboard_data.csv"))
def theme_summary(d):
"""How many reviews fall into each theme."""
if d.empty:
return pd.DataFrame(columns=["theme", "reviews", "pct"])
s = d.groupby("theme").size().sort_values(ascending=False).rename("reviews").reset_index()
s["pct"] = (100 * s["reviews"] / len(d)).round(1)
return s
def conclusion(text):
"""Blue takeaway box shown under each visualization."""
st.info(f"**Takeaway:** {text}")
# ---------------- Individual analysis sections ----------------
# Each function renders ONE section. Only the section the user selects is
# executed/rendered, so the page never mounts ~13 Plotly charts at once
# (which is what made the EDA page churn / never settle on the Space).
def section_themes(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("What Customers Praise vs. Complain About")
st.caption("Each bar is a recurring topic found in the reviews, and how often it shows up.")
tcol1, tcol2 = st.columns(2)
with tcol1:
st.subheader("Positive themes")
if summ_pos.empty:
st.info("No positive reviews in the current filter selection.")
else:
fig = px.bar(summ_pos.sort_values("reviews"), x="reviews", y="theme", orientation="h",
text="pct", color_discrete_sequence=[POS_COLOR])
fig.update_traces(texttemplate="%{text}%", textposition="outside")
fig.update_layout(height=420, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Reviews", yaxis_title="", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{summ_pos.iloc[0]['theme']}** is what customers love the most, "
f"making up {summ_pos.iloc[0]['pct']}% of positive reviews.")
with tcol2:
st.subheader("Negative Themes")
if summ_neg.empty:
st.info("No negative reviews in the current filter selection.")
else:
fig = px.bar(summ_neg.sort_values("reviews"), x="reviews", y="theme", orientation="h",
text="pct", color_discrete_sequence=[NEG_COLOR])
fig.update_traces(texttemplate="%{text}%", textposition="outside")
fig.update_layout(height=420, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Reviews", yaxis_title="", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{summ_neg.iloc[0]['theme']}** is the top complaint, making up "
f"{summ_neg.iloc[0]['pct']}% of negative reviews. This is the first thing to fix.")
def section_ratings(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("How Are Customers Actually Rating Their Experience?")
st.caption("A closer look using the actual 1-5 star score, instead of just good/bad.")
rcol1, rcol2 = st.columns([1, 1.3])
with rcol1:
st.subheader("Star Rating Distribution")
rating_counts = df["Customer Rating"].value_counts().sort_index()
fig = px.bar(x=rating_counts.index, y=rating_counts.values, color=rating_counts.index,
color_continuous_scale=["#c62828", "#ef6c00", "#fbc02d", "#7cb342", "#2e7d32"])
fig.update_layout(height=380, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Star rating", yaxis_title="Reviews",
showlegend=False, coloraxis_showscale=False, xaxis=dict(tickmode="linear"))
st.plotly_chart(fig, use_container_width=True)
most_common_rating = rating_counts.idxmax()
one_star_pct = 100 * rating_counts.get(1, 0) / rating_counts.sum()
conclusion(f"Ratings are split: **{most_common_rating}-star** is the most common score, but "
f"**{one_star_pct:.0f}%** of all reviews are 1-star, showing a clear group of unhappy customers.")
with rcol2:
st.subheader("Star Rating by Category")
cat_rating = df.groupby("Category")["Customer Rating"].agg(["mean", "count"])
cat_rating = cat_rating[cat_rating["count"] >= 10].sort_values("mean")
fig = px.bar(cat_rating.tail(15), x="mean", orientation="h", color="mean",
color_continuous_scale="RdYlGn", range_color=[1, 5])
fig.update_layout(height=380, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Average star rating", yaxis_title="",
showlegend=False, coloraxis_showscale=False)
st.plotly_chart(fig, use_container_width=True)
best_cat = cat_rating["mean"].idxmax()
worst_cat = cat_rating["mean"].idxmin()
conclusion(f"**{best_cat}** has the happiest customers ({cat_rating.loc[best_cat, 'mean']:.2f}/5), "
f"while **{worst_cat}** has the lowest average rating ({cat_rating.loc[worst_cat, 'mean']:.2f}/5).")
def section_emotions(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("How Do These Themes Make Customers Feel?")
st.caption("Each row adds up to 100%, showing which emotion dominates that topic.")
ecol1, ecol2 = st.columns(2)
with ecol1:
st.subheader("Positive Themes by Emotion")
d = df_pos[df_pos["theme"] != "Other"]
if d.empty:
st.info("No data for the current filters.")
else:
ct = pd.crosstab(d["theme"], d["Emotion"])
ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100
ct_pct = ct_pct.loc[d["theme"].value_counts().index]
fig = px.imshow(ct_pct, text_auto=".0f", aspect="auto", color_continuous_scale="Greens",
labels=dict(color="% within theme"))
fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10))
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{ct_pct.mean().idxmax()}** is the dominant emotion across most positive themes - "
f"customers aren't just satisfied, they're emotionally engaged.")
with ecol2:
st.subheader("Negative Themes by Emotion")
d = df_neg[df_neg["theme"] != "Other"]
if d.empty:
st.info("No data for the current filters.")
else:
ct = pd.crosstab(d["theme"], d["Emotion"])
ct_pct = ct.div(ct.sum(axis=1), axis=0) * 100
ct_pct = ct_pct.loc[d["theme"].value_counts().index]
fig = px.imshow(ct_pct, text_auto=".0f", aspect="auto", color_continuous_scale="Reds",
labels=dict(color="% within theme"))
fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10))
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{ct_pct.mean().idxmax()}** dominates negative reviews; knowing the emotion helps "
f"decide how customer service should respond (calm anger vs. reassure fear).")
def section_price(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Does Price Predict Satisfaction?")
st.caption("Products are split into 4 equal-sized price groups, from cheapest to most expensive.")
pcol1, pcol2 = st.columns(2)
df_price = df.copy()
try:
df_price["price_bracket"] = pd.qcut(df_price["Price"], q=4,
labels=["Budget", "Mid-low", "Mid-high", "Premium"],
duplicates="drop")
except ValueError:
df_price["price_bracket"] = pd.cut(df_price["Price"], bins=4)
with pcol1:
st.subheader("Rating Distribution by Price Bracket")
fig = px.box(df_price, x="price_bracket", y="Customer Rating", color="price_bracket",
category_orders={"price_bracket": ["Budget", "Mid-low", "Mid-high", "Premium"]})
fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Price bracket", yaxis_title="Customer rating", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
median_by_bracket = df_price.groupby("price_bracket", observed=True)["Customer Rating"].median()
cheapest_median = median_by_bracket.get("Budget", median_by_bracket.iloc[0])
priciest_median = median_by_bracket.get("Premium", median_by_bracket.iloc[-1])
if priciest_median > cheapest_median:
conclusion(f"The typical (median) rating rises with price: **Budget** items median "
f"{cheapest_median:.0f}/5 vs. **Premium** items at {priciest_median:.0f}/5.")
else:
conclusion(f"Median ratings don't rise consistently with price: **Budget** sits at "
f"{cheapest_median:.0f}/5 and **Premium** at {priciest_median:.0f}/5.")
with pcol2:
st.subheader("Negative Review Rate by Price Bracket")
neg_rate = (df_price.assign(is_neg=df_price["Sentiment"].eq("Negative"))
.groupby("price_bracket", observed=True)["is_neg"].mean().mul(100))
fig = px.bar(neg_rate, color_discrete_sequence=[NEG_COLOR],
category_orders={"price_bracket": ["Budget", "Mid-low", "Mid-high", "Premium"]})
fig.update_layout(height=400, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Price bracket", yaxis_title="% negative reviews", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
cheapest_rate = neg_rate.get("Budget", neg_rate.iloc[0])
priciest_rate = neg_rate.get("Premium", neg_rate.iloc[-1])
if cheapest_rate > priciest_rate:
conclusion(f"Cheaper products complain more: **Budget** items have a {cheapest_rate:.0f}% negative "
f"rate vs. only {priciest_rate:.0f}% for **Premium**. Price may signal quality expectations.")
else:
conclusion(f"Price doesn't clearly predict complaints: **Premium** items actually have a higher "
f"negative rate ({priciest_rate:.0f}%) than **Budget** items ({cheapest_rate:.0f}%).")
def section_geography(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Where Are Customers Reviewing From?")
st.caption("Top 15 cities by number of reviews, and how each one feels about their purchase.")
gcol1, gcol2 = st.columns(2)
top_locations = df["Location"].value_counts().head(15)
with gcol1:
st.subheader("Review Volume by Location")
fig = px.bar(top_locations.sort_values(), orientation="h", color_discrete_sequence=[ACCENT])
fig.update_layout(height=450, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Reviews", yaxis_title="", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{top_locations.idxmax()}** sends in the most reviews ({top_locations.max():,}), "
f"making it the biggest customer base worth prioritizing.")
with gcol2:
st.subheader("Negative Review Rate by Location")
loc_counts = df.groupby("Location").size()
loc_neg_rate = (df.assign(is_neg=df["Sentiment"].eq("Negative"))
.groupby("Location")["is_neg"].mean().mul(100))
loc_neg_rate = loc_neg_rate[loc_counts[loc_neg_rate.index] >= 10]
loc_neg_rate = loc_neg_rate.loc[loc_neg_rate.index.intersection(top_locations.index)]
fig = px.bar(loc_neg_rate.sort_values(), orientation="h", color_discrete_sequence=[NEG_COLOR])
fig.add_vline(x=loc_neg_rate.mean(), line_dash="dash", line_color="gray")
fig.update_layout(height=450, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="% negative reviews", yaxis_title="", showlegend=False)
st.plotly_chart(fig, use_container_width=True)
conclusion(f"**{loc_neg_rate.idxmax()}** has the highest share of negative reviews "
f"({loc_neg_rate.max():.0f}%), possibly pointing to local delivery or logistics issues.")
def section_category(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Which Categories Sell a Lot but Satisfy Little?")
st.caption("Each bubble is a category. Bigger bubble = more reviews.")
cat_perf = df.groupby("Category").agg(
avg_sold=("Number Sold", "mean"),
avg_rating=("Overall Rating", "mean"),
review_count=("Customer Rating", "count"),
).reset_index()
cat_perf = cat_perf[cat_perf["review_count"] >= 10]
fig = px.scatter(cat_perf, x="avg_sold", y="avg_rating", size="review_count", color="avg_rating",
color_continuous_scale="RdYlGn", range_color=[4, 5], hover_name="Category", size_max=45)
fig.update_layout(height=480, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Average units sold", yaxis_title="Average overall rating",
coloraxis_showscale=False)
st.plotly_chart(fig, use_container_width=True)
risk_cat = cat_perf.sort_values(["avg_sold", "avg_rating"], ascending=[False, True]).iloc[0]
conclusion(f"**{risk_cat['Category']}** sells the most but isn't the highest rated "
f"({risk_cat['avg_rating']:.2f}/5 average) - worth a closer look since it affects the most customers.")
def section_priority(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Which Complaints Should The Business Fix First?")
st.caption("X-axis = how often it happens. Y-axis = how angry/scared it makes customers. Top-right = fix first.")
d = df_neg[df_neg["theme"] != "Other"]
if d.empty:
st.info("No negative reviews for the current filters.")
else:
em = pd.crosstab(d["theme"], d["Emotion"])
for col in ["Anger", "Fear"]:
if col not in em.columns:
em[col] = 0
em_pct = em.div(em.sum(axis=1), axis=0) * 100
prio = pd.DataFrame({"volume": d["theme"].value_counts(),
"urgency": em_pct["Anger"] + em_pct["Fear"]})
fig = go.Figure()
fig.add_trace(go.Scatter(x=prio["volume"], y=prio["urgency"], mode="markers+text",
text=prio.index, textposition="top center",
marker=dict(size=14, color=NEG_COLOR), textfont=dict(size=10)))
fig.update_layout(height=500, margin=dict(l=10, r=10, t=10, b=10),
xaxis_title="Volume (number of reviews)",
yaxis_title="Urgency (% Anger + Fear)")
st.plotly_chart(fig, use_container_width=True)
top_priority = (prio["volume"].rank(pct=True) + prio["urgency"].rank(pct=True)).idxmax()
conclusion(f"**{top_priority}** scores highest on both volume and urgency - the single biggest "
f"opportunity to reduce complaints.")
def section_products(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Best and Worst Rated Products")
st.caption("Only products with 5+ reviews are shown, so one angry buyer can't skew the list.")
prod_stats = df.groupby(["Product Name", "Category"]).agg(
avg_rating=("Customer Rating", "mean"),
reviews=("Customer Rating", "count"),
price=("Price", "mean"),
).reset_index()
prod_stats = prod_stats[prod_stats["reviews"] >= 5]
bcol1, bcol2 = st.columns(2)
with bcol1:
st.subheader("Top Rated")
top_prod = prod_stats.sort_values(["avg_rating", "reviews"], ascending=[False, False]).head(10)
st.dataframe(top_prod.assign(avg_rating=top_prod["avg_rating"].round(2)).reset_index(drop=True),
use_container_width=True, height=380)
with bcol2:
st.subheader("Lowest Rated")
bottom_prod = prod_stats.sort_values(["avg_rating", "reviews"], ascending=[True, False]).head(10)
st.dataframe(bottom_prod.assign(avg_rating=bottom_prod["avg_rating"].round(2)).reset_index(drop=True),
use_container_width=True, height=380)
if not bottom_prod.empty:
conclusion(f"The lowest-rated qualifying product is **{bottom_prod.iloc[0]['Product Name']}** "
f"at {bottom_prod.iloc[0]['avg_rating']:.2f}/5 - a candidate for delisting or seller follow-up.")
def section_explorer(df, df_pos, df_neg, summ_pos, summ_neg):
st.header("Review Explorer")
st.caption("Pick a theme or search a keyword to read the actual reviews behind the numbers.")
sel_theme = st.multiselect("Filter by theme", sorted(df["theme"].unique()), default=[])
explorer_df = df.copy()
if sel_theme:
explorer_df = explorer_df[explorer_df["theme"].isin(sel_theme)]
search = st.text_input("Search review text (optional)", "")
if search:
explorer_df = explorer_df[explorer_df["Customer Review"].str.contains(search, case=False, na=False)]
st.dataframe(
explorer_df[["Category", "Product Name", "Sentiment", "Emotion", "theme", "Customer Review"]]
.reset_index(drop=True),
use_container_width=True, height=400,
)
st.caption(f"Showing {len(explorer_df):,} of {len(df):,} filtered reviews.")
# Ordered registry of sections: label -> render function.
SECTIONS = {
"Praise vs Complaints": section_themes,
"Star Ratings": section_ratings,
"Emotions by Theme": section_emotions,
"Price vs Satisfaction": section_price,
"Geography": section_geography,
"Category Performance": section_category,
"Priority Matrix": section_priority,
"Best & Worst Products": section_products,
"Review Explorer": section_explorer,
}
def run():
reviews = load_data()
ALL_CATEGORIES = sorted(reviews["Category"].unique())
ALL_SENTIMENTS = sorted(reviews["Sentiment"].unique())
ALL_EMOTIONS = sorted(reviews["Emotion"].unique())
# ---------------- Sidebar filters ----------------
st.sidebar.title("Filters")
st.sidebar.caption("Filters apply to every section below.")
sel_sentiment = st.sidebar.multiselect("Sentiment", ALL_SENTIMENTS, default=ALL_SENTIMENTS)
sel_category = st.sidebar.multiselect("Product category", ALL_CATEGORIES, default=[])
sel_emotion = st.sidebar.multiselect("Emotion", ALL_EMOTIONS, default=[])
df = reviews.copy()
if sel_sentiment:
df = df[df["Sentiment"].isin(sel_sentiment)]
if sel_category:
df = df[df["Category"].isin(sel_category)]
if sel_emotion:
df = df[df["Emotion"].isin(sel_emotion)]
if df.empty:
st.warning("No reviews match the current filters. Try widening your selection in the sidebar.")
st.stop()
df_pos = df[df["Sentiment"] == "Positive"]
df_neg = df[df["Sentiment"] == "Negative"]
summ_pos = theme_summary(df_pos[df_pos["theme"] != "Other"])
summ_neg = theme_summary(df_neg[df_neg["theme"] != "Other"])
# ---------------- Header ----------------
st.title("Tokopedia Review Insights")
st.caption("What do customers actually praise and complain about, and what should the business do about it?")
# ---------------- KPI row (always shown, lightweight) ----------------
total = len(df)
pct_pos = 100 * len(df_pos) / total if total else 0
pct_neg = 100 * len(df_neg) / total if total else 0
top_complaint = summ_neg.iloc[0]["theme"] if not summ_neg.empty else "-"
c1, c2, c3, c4, c5 = st.columns(5)
c1.metric("Total reviews", f"{total:,}")
c2.metric("Positive", f"{len(df_pos):,}", f"{pct_pos:.0f}% of total")
c3.metric("Negative", f"{len(df_neg):,}", f"{pct_neg:.0f}% of total", delta_color="inverse")
c4.metric("Avg. customer rating", f"{df['Customer Rating'].mean():.2f} / 5")
c5.metric("Top complaint", top_complaint if len(top_complaint) < 22 else top_complaint[:20] + "...")
st.divider()
# ---------------- Section selector ----------------
# Only the chosen section's charts are built/rendered. This keeps the page
# light enough to settle reliably on the Space (instead of churning while it
# tries to mount every chart at once).
choice = st.radio("Choose an analysis", list(SECTIONS.keys()), horizontal=True)
st.divider()
SECTIONS[choice](df, df_pos, df_neg, summ_pos, summ_neg)
st.divider()
st.caption("Topic Pulse | Tokopedia Review Insight - FTDS-040-HCK Group 001")