Case Study 27-2: Maya's Portfolio Health Check

The Setup

Maya Reyes has been running her consulting practice for three and a half years. She works with eight to twelve active clients at any given time — mostly mid-sized companies in manufacturing, logistics, and professional services who hire her for process improvement projects, analytics strategy, and the occasional executive workshop.

Business has been steady. Invoices are going out. Payments are coming in. But on a rainy Wednesday evening in late January, Maya opens her client tracking spreadsheet and feels a vague unease she cannot quite name.

She has been so busy with active engagements that she has not looked at the full portfolio view in months. When she does, she notices something: two clients she thinks of as anchors — Meridian Logistics and Carver & Associates — have not sent her a new project inquiry in almost three months. Historically, both companies were in touch with her every four to six weeks about something.

"Maybe they're just quiet," she tells herself.

Maybe. But she has read enough about customer churn to know that "going quiet" is often the first symptom, not the last one.


Adapting RFM for a Consulting Practice

Maya's situation is different from a retailer's. She does not have hundreds of customers. She has a portfolio of relationships. But the same underlying logic applies:

  • Recency: When did a client last contact her about a new engagement?
  • Frequency: How often do they typically start new projects with her?
  • Monetary: What is their average annual spend with her practice?

The difference is that Maya can score these more subjectively — she knows her clients well enough to calibrate — and she does not need quintiles. She needs a simple framework that tells her where to direct her attention.

import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches

# Maya's client portfolio data
# In practice, pulled from her project tracking CSV
client_data = {
    "client_name": [
        "Meridian Logistics",
        "Carver & Associates",
        "Thornton Manufacturing",
        "Bellview Healthcare",
        "Summit Retail Group",
        "Okafor Strategy Partners",
        "Greystone Capital",
        "Lakewood City Schools",
    ],
    "last_project_inquiry_date": [
        "2023-10-08",   # Meridian — quiet since October
        "2023-10-21",   # Carver — quiet since late October
        "2024-01-03",   # Thornton — recent
        "2024-01-11",   # Bellview — very recent
        "2023-12-15",   # Summit — about a month ago
        "2023-11-28",   # Okafor — about 7 weeks ago
        "2023-12-01",   # Greystone — about 6 weeks ago
        "2023-11-05",   # Lakewood — about 10 weeks ago
    ],
    "projects_last_12_months": [4, 3, 2, 1, 3, 2, 1, 2],
    "annual_revenue_usd": [
        52_000, 41_000, 28_000, 14_000,
        38_000, 24_000, 17_500, 21_000,
    ],
    "relationship_years": [2.8, 2.1, 0.9, 0.3, 1.6, 1.1, 0.7, 1.8],
}

clients = pd.DataFrame(client_data)
clients["last_project_inquiry_date"] = pd.to_datetime(
    clients["last_project_inquiry_date"]
)

analysis_date = pd.Timestamp("2024-01-24")
clients["days_since_last_inquiry"] = (
    analysis_date - clients["last_project_inquiry_date"]
).dt.days

print(clients[[
    "client_name", "days_since_last_inquiry",
    "projects_last_12_months", "annual_revenue_usd"
]])

Output:

               client_name  days_since_last_inquiry  projects_last_12_months  annual_revenue_usd
0      Meridian Logistics                      108                        4               52000
1      Carver & Associates                      95                        3               41000
2  Thornton Manufacturing                       21                        2               28000
3       Bellview Healthcare                      13                        1               14000
4       Summit Retail Group                      40                        3               38000
5  Okafor Strategy Partners                      57                        2               24000
6          Greystone Capital                      54                        1               17500
7      Lakewood City Schools                      80                        2               21000

Building the Simplified RFM Scores

Maya creates a simplified 1–3 scoring scale. With only eight clients, quintile binning is overkill and would produce meaningless results. Instead, she defines the thresholds manually based on her knowledge of her own business rhythms.

def score_recency(days):
    """
    1–3 score for how recently the client engaged.
    Maya's clients typically reach out every 4–8 weeks,
    so 90+ days is genuinely concerning.
    """
    if days <= 30:
        return 3   # Active
    elif days <= 60:
        return 2   # Cooling
    else:
        return 1   # Quiet / At risk


def score_frequency(projects):
    """
    1–3 score for project frequency in the last 12 months.
    1–2 projects = low, 3 = medium, 4+ = high.
    """
    if projects >= 4:
        return 3
    elif projects >= 2:
        return 2
    else:
        return 1


def score_monetary(revenue):
    """
    1–3 score for annual revenue.
    < $20K = low, $20K–$35K = medium, > $35K = high.
    """
    if revenue >= 35_000:
        return 3
    elif revenue >= 20_000:
        return 2
    else:
        return 1


clients["r_score"] = clients["days_since_last_inquiry"].apply(score_recency)
clients["f_score"] = clients["projects_last_12_months"].apply(score_frequency)
clients["m_score"] = clients["annual_revenue_usd"].apply(score_monetary)
clients["rfm_total"] = clients["r_score"] + clients["f_score"] + clients["m_score"]

print(clients[[
    "client_name", "r_score", "f_score", "m_score", "rfm_total"
]].sort_values("rfm_total", ascending=False).to_string(index=False))

Output:

            client_name  r_score  f_score  m_score  rfm_total
   Summit Retail Group        3        2        3          8
 Thornton Manufacturing        3        2        2          7
     Bellview Healthcare        3        1        1          5
Okafor Strategy Partners        2        2        2          6
       Greystone Capital        2        1        1          4
   Lakewood City Schools        1        2        2          5
    Carver & Associates        1        3        3          7
     Meridian Logistics        1        3        3          7

The Two Clients That Need Attention

The number that jumps out is Meridian Logistics and Carver & Associates. Both have R-scores of 1 (quiet for 95–108 days) but F-scores and M-scores of 3 — meaning they were high-engagement, high-value clients.

This is the exact "At Risk" pattern: historically strong, currently disengaged.

def assign_client_segment(row):
    """Segment assignment for Maya's portfolio (using 1–3 scale)."""
    r, f, m = row["r_score"], row["f_score"], row["m_score"]

    if r == 3 and f >= 2 and m >= 2:
        return "Active & Valuable"
    elif r == 3 and (f == 1 or m == 1):
        return "Active & Growing"
    elif r == 2 and f >= 2:
        return "Stable"
    elif r == 2 and f == 1:
        return "Cooling"
    elif r == 1 and f >= 2 and m >= 2:
        return "At Risk"
    elif r == 1:
        return "Lapsed"
    else:
        return "Monitor"


clients["segment"] = clients.apply(assign_client_segment, axis=1)

# Print the full portfolio view
print("\nMaya's Client Portfolio — Health Assessment")
print("=" * 60)
for _, row in clients.sort_values("rfm_total", ascending=False).iterrows():
    flag = " *** PRIORITY ***" if row["segment"] == "At Risk" else ""
    print(f"\n{row['client_name']}{flag}")
    print(f"  Segment: {row['segment']}")
    print(f"  Days since inquiry: {row['days_since_last_inquiry']}")
    print(f"  Projects (12 mo): {row['projects_last_12_months']}")
    print(f"  Annual revenue: ${row['annual_revenue_usd']:,}")
    print(f"  RFM total: {row['rfm_total']}/9")

Visualizing the Portfolio

Maya builds a simple 2x2 scatter plot — recency on the X-axis, annual revenue on the Y-axis — to see the portfolio visually.

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

# Color by segment
segment_colors = {
    "Active & Valuable": "#1a9850",
    "Active & Growing": "#91cf60",
    "Stable": "#74add1",
    "Cooling": "#fee090",
    "At Risk": "#d73027",
    "Lapsed": "#878787",
    "Monitor": "#f0e442",
}

for _, row in clients.iterrows():
    color = segment_colors.get(row["segment"], "#888888")
    ax.scatter(
        row["days_since_last_inquiry"],
        row["annual_revenue_usd"],
        s=row["projects_last_12_months"] * 120,   # size = project frequency
        c=color,
        edgecolors="#333333",
        linewidths=0.8,
        zorder=3,
        alpha=0.85,
    )
    ax.annotate(
        row["client_name"].split()[0],   # first word of client name
        (row["days_since_last_inquiry"], row["annual_revenue_usd"]),
        textcoords="offset points",
        xytext=(8, 4),
        fontsize=9,
    )

# Reference lines
ax.axvline(60, color="#aaaaaa", linestyle="--", linewidth=1.0,
           alpha=0.7, label="60-day threshold")
ax.axvline(90, color="#d73027", linestyle="--", linewidth=1.0,
           alpha=0.7, label="90-day alert threshold")

ax.set_xlabel("Days Since Last Project Inquiry", fontsize=12)
ax.set_ylabel("Annual Revenue ($)", fontsize=12)
ax.set_title(
    "Maya Reyes Consulting — Client Portfolio Health\n"
    "(Bubble size = projects in last 12 months)",
    fontsize=13,
    fontweight="bold",
)
ax.yaxis.set_major_formatter(
    plt.FuncFormatter(lambda x, _: f"${x:,.0f}")
)

# Legend for segments
legend_patches = [
    mpatches.Patch(color=c, label=seg)
    for seg, c in segment_colors.items()
    if seg in clients["segment"].values
]
ax.legend(handles=legend_patches, loc="upper right", fontsize=9,
          title="Segment", title_fontsize=9)
ax.grid(True, alpha=0.25)
ax.spines[["top", "right"]].set_visible(False)

plt.tight_layout()
plt.savefig("maya_client_portfolio.png", dpi=150, bbox_inches="tight")
plt.show()
print("Portfolio chart saved.")

The visual makes the situation unmistakable. Meridian Logistics and Carver & Associates are sitting in the upper-right of the danger zone: high value, long silence.


Maya Reaches Out

That same Wednesday evening, Maya drafts two emails. They are not form letters. She writes one for each client, referencing the specific work they did together and raising a natural next step.

To Meridian Logistics:

"Hi David — I've been thinking about your supply chain consolidation project we wrapped up in October, and particularly that bottleneck we identified in the inbound receiving process but didn't have time to dig into. I'd love to schedule 30 minutes to catch up and hear how Q4 went for you. Any chance you're free next week?"

To Carver & Associates:

"Hi Pritha — It's been a few months since we finished the pricing strategy review, and I'm curious how the new framework has been performing. Also, I recall you mentioned last time that the operations team was dealing with some workflow challenges — if that's still on your list, I'd be glad to connect about it. Happy to do a no-obligation call just to catch up."

Both emails go out by 8 p.m. By the following morning, both clients have replied.

David at Meridian: "Perfect timing, actually — we've been meaning to loop you back in on the receiving process. Can we do Thursday?"

Pritha at Carver: "Maya! I was literally drafting an email to you yesterday and got pulled into a fire. Yes, let's talk. The workflow issue has gotten worse."


What Maya Learned

Portfolio thinking changes how you manage client relationships. When you are heads-down in active projects, it is easy to let quieter clients drift. An RFM-style framework — even a simplified, eight-client version — forces you to look at the whole portfolio at once, not just the clients who are currently emailing you.

Recency is the variable most likely to surprise you. Frequency and value are things you generally know intuitively about your best clients. But you may not realize how long it has been since you last heard from a client — especially one you assume is satisfied. The data corrects that assumption.

Small datasets still benefit from structure. Maya could have done this in her head, or on a whiteboard. But writing the code forced her to be explicit about her thresholds and made the output shareable. When she wanted to explain the health check to her accountant (who was asking about revenue risk), she could send her a one-page summary generated from the script.

Proactive outreach is not pushy — it is professional. Both of Maya's emails were warmly received precisely because they were specific and timely, not because they were aggressive. The analysis gave her the trigger to reach out; her client knowledge gave her the content to do it well.

Make this a habit, not a one-time exercise. Maya decides to run her portfolio health check on the first of every month — no more than thirty minutes of work, but a consistent discipline that prevents the "going quiet" problem from sneaking up on her again.


The Numbers After 60 Days

Two months later, Maya runs the analysis again. Both Meridian and Carver have moved out of the "At Risk" segment into "Active & Valuable." The Thursday meeting with Meridian turned into a four-month engagement. The call with Pritha at Carver became a six-week workflow redesign project starting in March.

Combined, those two re-engagements represent approximately $67,000 in revenue that Maya almost let slip away — not because she had done anything wrong, but because she had not been watching.

Now she is watching.