Back
GuidesAnalog Modeling: Look-Alike Site SelectionFinding candidate malls for an in-mall retailer
Python
USA

Finding candidate malls for an in-mall retailer

This example shows how to run analog modeling for an in-mall retailer in Colorado: classify every mall on measured dimensions, join the retailer's own store performance to find the class where it performs, and shortlist the malls of that class with no store yet.

For this we use our US Places metrics, queried from Python. The helper below is all the setup the example needs:

One part of this example is fictional. Meridian Trail Co., its seven stores, and the sales column in section 4 are invented to show how your own performance data joins the classification. Every other number is measured Colorado data for calendar 2025.

import os
import requests
import pandas as pd

API = "https://api.pine59.com/v3/datasets"
HEADERS = {"Authorization": f"Bearer {os.environ['PINE59_API_KEY']}"}

def run_query(dataset, body):
    r = requests.post(f"{API}/{dataset}:runQuery", json=body, headers=HEADERS)
    r.raise_for_status()
    return [rec["fields"] for rec in r.json().get("records", [])]

1. The mall universe

Malls carry sub_category Malls in the places data, a category that spans super-regional centers and strip plazas alike. We pull every Colorado mall's monthly visits for 2025, one month per request so each response fits one page, and sum them:

MALL = [
    {"fieldName": "sub_category", "operator": "==", "value": "Malls"},
    {"fieldName": "us_state",     "operator": "==", "value": "Colorado"},
]

rows = []
for month in pd.date_range("2025-01-01", "2025-12-01", freq="MS"):
    rows += run_query("visitation_usa_places.foot_traffic_month", {
        "fields": [
            {"name": "location_id"},
            {"name": "location_name"},
            {"name": "city"},
            {"name": "visits_sum", "aggregation": "SUM"},
        ],
        "filters": MALL + [{"fieldName": "observation_start_date", "operator": "==",
                            "value": month.strftime("%Y-%m-%d")}],
        "groupBy": [{"fieldName": "location_id"}, {"fieldName": "location_name"},
                    {"fieldName": "city"}],
        "pageSize": 10000,
    })

df = pd.DataFrame(rows)
df["visits"] = df["visits_sum_SUM"].astype(float)
malls = (df.groupby(["location_id", "location_name", "city"], as_index=False)["visits"].sum()
           .query("visits >= 1_000_000")
           .set_index("location_id"))
print(len(malls), "malls above one million visits in 2025")

795 malls come back; 428 clear the one-million-visit floor that screens out strip plazas.

2. Tenants, anchors, and visitor profile

Every store inside a mall carries the mall's Placekey as parent_placekey, so the same dataset, filtered to the malls' Placekeys in chunks of fifty, returns each tenant with its own 2025 visits. An anchor is a tenant with at least 300,000 annual visits, roughly a busy department store or supermarket:

ids = list(malls.index)
tenants = []
for i in range(0, len(ids), 50):
    tenants += run_query("visitation_usa_places.foot_traffic_month", {
        "fields": [
            {"name": "location_id"},
            {"name": "parent_placekey"},
            {"name": "visits_sum", "aggregation": "SUM"},
        ],
        "filters": [
            {"fieldName": "parent_placekey", "operator": "in", "values": ids[i:i + 50]},
            {"fieldName": "observation_start_date", "operator": ">=", "value": "2025-01-01"},
            {"fieldName": "observation_start_date", "operator": "<",  "value": "2026-01-01"},
        ],
        "groupBy": [{"fieldName": "location_id"}, {"fieldName": "parent_placekey"}],
        "pageSize": 10000,
    })

t = pd.DataFrame(tenants)
t["visits"] = t["visits_sum_SUM"].astype(float)
malls["tenants"] = t.groupby("parent_placekey").size()
malls["anchors"] = t[t["visits"] >= 300_000].groupby("parent_placekey").size()
malls[["tenants", "anchors"]] = malls[["tenants", "anchors"]].fillna(0).astype(int)

Who visits comes from two quarterly datasets (Q4 2025 here): census_visitor_demographics for the share of visitors from households earning $125k and above, and spatialai_visitor_profile for the leading lifestyle segment:

demo = run_query("visitation_usa_places.census_visitor_demographics", {
    "fields": [{"name": "location_id"}, {"name": "people_fraction_income_125k_and_above"}],
    "filters": MALL + [{"fieldName": "observation_start_date", "operator": "==", "value": "2025-10-01"}],
    "pageSize": 10000,
})
malls["income_125k"] = (pd.DataFrame(demo).set_index("location_id")
                          ["people_fraction_income_125k_and_above"].astype(float))

profile = []
for i in range(0, len(ids), 150):
    profile += run_query("visitation_usa_places.spatialai_visitor_profile", {
        "fields": [{"name": "location_id"}, {"name": "segment_family"}, {"name": "people_fraction"}],
        "filters": [
            {"fieldName": "location_id", "operator": "in", "values": ids[i:i + 150]},
            {"fieldName": "observation_start_date", "operator": "==", "value": "2025-10-01"},
        ],
        "pageSize": 10000,
    })
p = pd.DataFrame(profile)
p["people_fraction"] = p["people_fraction"].astype(float)
top = p.sort_values("people_fraction", ascending=False).drop_duplicates("location_id").set_index("location_id")
malls["top_segment"] = top["segment_family"]
malls["top_share"] = top["people_fraction"]

Park Meadows' visitors are 47% Ultra Wealthy Families; Mesa Mall in Grand Junction leads with Blue Collar Suburbs at 20%.

3. Classification

With traffic, tenant depth, anchors, and affluence per mall, classification is a few transparent rules:

state_median = malls["income_125k"].median()

def mall_class(m):
    scale = ("destination" if m.tenants >= 50 and m.anchors >= 3
             else "regional" if m.visits >= 2_000_000 or m.tenants >= 25
             else "community")
    affluence = "affluent" if m.income_125k >= state_median else "broad"
    return f"{scale}-{affluence}"

malls["mall_class"] = malls.apply(mall_class, axis=1)
print(malls["mall_class"].value_counts())

Destination is defined by tenant depth and anchors, not traffic: Aurora City Place, a power center of supermarket and big-box trips, ranks second in the state on traffic (24.5M visits) with 44 tenants, while Twenty Ninth Street in Boulder, ranked 181st (5.0M), has 66 in-line tenants and 6 anchors. For an in-mall retailer, co-tenancy is the signal.

The rules yield 10 destination-affluent, 4 destination-broad, 116 regional-affluent, 121 regional-broad, and 177 community centers. The destination tier, by 2025 traffic:

MallCityVisitsTenantsAnchors$125k+ shareTop segment familyClass
The Streets At SouthglennCentennial25.1M57540%Ultra Wealthy Families (31%)destination-affluent
The Shops At NorthfieldDenver21.9M58424%Young Professionals (19%)destination-broad
Orchard Town CenterWestminster21.1M86932%Upper Suburban Diverse Families (25%)destination-affluent
Flatiron CrossingBroomfield20.9M110430%Young Professionals (19%)destination-affluent
Southlands Town CenterAurora20.3M1081346%Wealthy Suburban Families (39%)destination-affluent
Park MeadowsLone Tree20.3M1491349%Ultra Wealthy Families (47%)destination-affluent
Front Range VillageFort Collins20.2M64631%Upper Suburban Diverse Families (24%)destination-affluent
CornerstarAurora19.9M50936%Wealthy Suburban Families (21%)destination-affluent
Promenade At Castle RockCastle Rock15.7M681551%Ultra Wealthy Families (42%)destination-affluent
Southwest PlazaLittleton14.1M51633%Upper Suburban Diverse Families (32%)destination-affluent
Colorado MillsLakewood12.1M102327%Upper Suburban Diverse Families (23%)destination-broad
Twenty Ninth StreetBoulder5.0M66630%Young Professionals (41%)destination-affluent
Town Center At AuroraAurora4.7M56517%Young Professionals (20%)destination-broad
Mesa MallGrand Junction4.4M58614%Blue Collar Suburbs (20%)destination-broad

How to read it: the affluent tier is the metro's suburban centers plus Boulder and Fort Collins; the broad tier is Northfield, Colorado Mills, Town Center at Aurora, and Mesa Mall. Colorado Mills sits just under the state median (27.3% vs. 27.5%); a median split is a choice, and malls this close to the line deserve a look on both sides.

4. Sales by class

This is the fictional step. Meridian Trail Co. operates seven Colorado mall stores with a made-up sales index. With a real network this table is your own sales system keyed by store, and the join is one merge on mall name or Placekey:

sales = pd.DataFrame({
    "location_name": ["Park Meadows", "Twenty Ninth Street", "Flatiron Crossing",
                      "The Promenade Shops At Centerra", "The Shops At Northfield",
                      "Town Center At Aurora", "The Citadel Mall"],
    "sales_index":   [100, 96, 91, 73, 61, 48, 44],   # fictional
})
network = sales.merge(malls.reset_index()[["location_name", "mall_class"]], on="location_name")
print(network.groupby("mall_class")["sales_index"].agg(["median", "size"]))
Store (mall)ClassSales index (fictional)
Park Meadowsdestination-affluent100
Twenty Ninth Streetdestination-affluent96
Flatiron Crossingdestination-affluent91
The Promenade Shops At Centerraregional-affluent73
The Shops At Northfielddestination-broad61
Town Center At Auroradestination-broad48
The Citadel Mallregional-broad44

Median sales index per class: destination-affluent 96 (3 stores), regional-affluent 73 (1), destination-broad 54.5 (2), regional-broad 44 (1).

How to read it: the classification says what kinds of malls exist; your own sales say which kind performs for you. Here the class medians order cleanly: this retailer performs in destination-affluent malls, holds up in regional-affluent, and underperforms where the visitor base is broad. Read class medians directionally: a class with one store is a data point, not a benchmark, and affluence ordering the same way at both scales carries more weight than any single cell. A value chain may find its best stores in regional-broad centers.

5. Candidate malls

The shortlist is a filter: destination-affluent malls with no store yet.

candidates = malls[(malls["mall_class"] == "destination-affluent")
                   & ~malls["location_name"].isin(sales["location_name"])]
print(candidates.sort_values("visits", ascending=False)
                [["location_name", "city", "visits", "tenants", "anchors", "income_125k", "top_segment"]])
Candidate mallCityVisits (2025)TenantsAnchors$125k+ shareTop segment family
The Streets At SouthglennCentennial25.1M57540%Ultra Wealthy Families (31%)
Orchard Town CenterWestminster21.1M86932%Upper Suburban Diverse Families (25%)
Southlands Town CenterAurora20.3M1081346%Wealthy Suburban Families (39%)
Front Range VillageFort Collins20.2M64631%Upper Suburban Diverse Families (24%)
CornerstarAurora19.9M50936%Wealthy Suburban Families (21%)
Promenade At Castle RockCastle Rock15.7M681551%Ultra Wealthy Families (42%)
Southwest PlazaLittleton14.1M51633%Upper Suburban Diverse Families (32%)

How to read it: seven candidates with the traffic, co-tenancy, and visitor profile the network's best stores share. The screen does not know lease availability, rents, formats, or the pipeline; that judgment comes next. Regional-affluent held up in the sales join, so its 116 malls are the second tier: the same filter, one value changed.

Every cut here is a parameter, not a truth: the one-million-visit floor, the 50-tenant and 3-anchor destination bar, the 300k anchor definition, the state median as the affluence split. A luxury brand and a value chain draw these lines differently from the same four queries.

Did you find what you were looking for?