| 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}") |
|
|
|
|
| |
| |
| |
| |
|
|
| 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.") |
|
|
|
|
| |
| 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()) |
|
|
| |
| 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"]) |
|
|
| |
| st.title("Tokopedia Review Insights") |
| st.caption("What do customers actually praise and complain about, and what should the business do about it?") |
|
|
| |
| 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() |
|
|
| |
| |
| |
| |
| 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") |
|
|