Skip to content

Open in Colab Launch Binder

Find daily mobility motifs

Question. What does a typical day of movement look like for each person: how many places do they visit, in what pattern, starting and ending at home?

You will get one daily mobility motif per person and day, the share of days that follow each motif, and the actual place sequences behind the most common one.

Prerequisites. Build the trajectory hierarchy (stops and places) and Routine, predictability and mobility profiles (the visit table used here is built the same way).

Data. GeoLife: the first 80 trips of users 000 to 005 (about 630,000 fixes, roughly 25 MB read from the 313 MB archive) with fastmob.data.load_geolife; times converted to Beijing time. The notebook stops if the download fails.

%pip install -q 'fastmob>=0.3.4' pandas matplotlib

What a motif is

A daily motif describes the shape of one day's movement between places, ignoring which places they are. A day that goes home, work, home has the same motif for everybody; a day that goes home, shop, work, home is a different one. Motifs let you compare daily routines across people and cities without sharing coordinates. Fastkit-Mobility's motifs are home-anchored: each day's sequence starts at the place where the person spent the previous night (or their main home) and, if the day does not end at home, is closed back at home. The motif id packs the number of distinct places into its high bits (motif_id // 2**36) and a canonical form of the directed movement graph into its low bits, so two days with the same id have the same graph. A day with more than six distinct places gets the id -1. Home is known from Locations.identify.

Daily mobility motifs: home-anchored movement graphs and their isomorphism classes

Build the visits

The same pipeline as in the previous notebooks: local time, stops, per-person places, home and work labels.

import fastmob
import pandas as pd
from fastmob.data import load_geolife

tdf = load_geolife(users=[f"{i:03d}" for i in range(6)], max_trips_per_user=80)

tdf.timezone_conversion(from_timezone="UTC", to_timezone="Asia/Shanghai")
fixes = fastmob.filter(tdf.to_pandas(), max_speed_kmh=200)
staypoints = fastmob.Positionfixes(fixes).generate_staypoints(
    minutes_for_a_stop=20, spatial_radius_km=0.2, no_data_for_minutes=30
)
locations, staypoints = staypoints.generate_user_locations(epsilon_km=0.2)
locations = locations.identify(staypoints)

print(len(staypoints), "staypoints,", len(locations), "places")
504 staypoints, 105 places

One motif per person and day, from the typed tables

Staypoints.generate_daily_motifs takes the staypoints and the labelled places and returns one row per user and day with a motif_id.

daily = staypoints.generate_daily_motifs(locations)
daily.head()
uid date motif_id
0 000 2008-10-23 137438953478
1 000 2008-10-28 137438953478
2 000 2008-11-04 206158430348
3 000 2008-11-11 137438953478
4 000 2008-11-13 137438953478

Each row is one person-day with its motif id: 210 person-days in total. Days on which a person was not recorded have no row.

The same from a plain visit table

daily_motifs is the function behind it, for visit tables you already have. It needs the user, the place, the purpose, the start and end times of each visit, and it recognises home only when the purpose is spelled HOME in capitals, so we upper-case the labels.

from fastmob.measures.individual import daily_motifs, motif_distribution

visits = staypoints.to_pandas().merge(
    locations.to_pandas()[["uid", "location_id", "purpose"]], on=["uid", "location_id"]
)
visits["purpose"] = visits["purpose"].str.upper()
daily_b = daily_motifs(
    visits,
    uid_col="uid",
    location_col="location_id",
    purpose_col="purpose",
    datetime_col="started_at",
    end_datetime_col="finished_at",
)

print(
    len(daily),
    "motif-days from the typed tables;",
    len(daily_b),
    "from the plain visit table",
)
daily_b.head()
210 motif-days from the typed tables; 210 from the plain visit table
uid date motif_id
0 000 2008-10-23 137438953478
1 000 2008-10-28 137438953478
2 000 2008-11-04 206158430348
3 000 2008-11-11 137438953478
4 000 2008-11-13 137438953478

The plain visit table gives the same 210 person-days and the same ids, as it should: it is the same computation without the typed classes.

How common is each motif?

motif_distribution counts the days per motif and gives their percentage.

distribution = motif_distribution(daily)
distribution.assign(places=distribution["motif_id"] // 2**36).sort_values(
    "count", ascending=False
)
motif_id count percentage places
0 137438953478 108 51.428571 2
2 68719476736 55 26.190476 1
1 206158430348 29 13.809524 3
3 274877923864 5 2.380952 4
6 206158430378 4 1.904762 3
5 206158430436 3 1.428571 3
4 343605905456 3 1.428571 5
7 429632004192 2 0.952381 6
8 343612604464 1 0.476190 5

Half of the days (108 of 210, 51%) have a two-place motif (home plus one other place), a quarter (26%) a one-place motif (the whole day at one place) and 14% a three-place motif. Longer motifs are rare. The places column is the number of distinct places read from the id.

Look at the days behind the motifs

To check what a motif means, show two example days for each of the three most common motifs: the places visited, in order, written as the first letter of the purpose (H home, W work, O other) and the place id.

visits["date"] = visits["started_at"].dt.date
for motif_id in distribution.nlargest(3, "count")["motif_id"]:
    print("motif", motif_id)
    for _, row in (
        daily[daily["motif_id"] == motif_id].sample(2, random_state=1).iterrows()
    ):
        day = visits[
            (visits["uid"] == row["uid"])
            & (visits["date"] == pd.Timestamp(row["date"]).date())
        ].sort_values("started_at")
        print(
            "   ",
            row["uid"],
            row["date"],
            ":",
            " > ".join(
                f"{p}{l}" for p, l in zip(day["purpose"].str[0], day["location_id"])
            ),
        )
motif 137438953478
    005 2008-11-25 : W0 > W0 > W0 > W0 > W0 > W0 > W0 > W0
    004 2008-12-15 : H0 > O2
motif 68719476736
    003 2008-11-20 : H0 > H0


    001 2008-11-09 : H1
motif 206158430348
    003 2008-11-07 : O1 > O1 > O10
    004 2008-11-21 : O1 > O1 > O2

The days are shorter than they look. Motif 137438953478 (two places) covers a day of eight stops at one work place and a day that goes from home to one other place: in both, home is added as the anchor, so the day is home, one place, home. The one-place motif is a day at a single place, and the three-place motif above is a day at two non-home places that the anchor turns into home, place, place, home. Many motifs here are simple because GeoLife users were tracked only for parts of each day.

Interpretation and limits

  • A motif is only as good as the home label and the stop detection behind it. A day without a recognised home, or with stops that were missed, gets a different motif.
  • Days are calendar days in the data's own time (Beijing time here), so a night-time activity after midnight belongs to the next day.
  • GeoLife users were not tracked every day: days with no recording have no motif, so the counts describe recorded days.
  • This sample has six people; motif shares need hundreds of people before they say anything about a population.

Next steps

daily_motifs · motif_distribution · generate_daily_motifs