Как эффективно вычислить скользящее уникальное количество во временном ряду панд?

У меня есть временной ряд людей, посещающих здание. У каждого человека есть уникальный идентификатор. Для каждой записи во временном ряду я хочу знать количество уникальных людей, посетивших здание за последние 365 дней (т. е. скользящий уникальный подсчет с окном в 365 дней).

pandas, похоже, не имеет встроенного метода для этого расчета. Расчет становится ресурсоемким, когда имеется большое количество уникальных посетителей и/или большое окно. (Фактические данные больше, чем в этом примере.)

Есть ли лучший способ расчета, чем то, что я сделал ниже? Я не уверен, почему быстрый метод, который я сделал, windowed_nunique (в разделе «Тест скорости 3»), отключен на 1.

Спасибо за любую помощь!

Ссылки по теме:

Инициализация

In [1]:

# Import libraries.
import pandas as pd
import numba
import numpy as np

In [2]:

# Create data of people visiting a building.

np.random.seed(seed=0)
dates = pd.date_range(start='2010-01-01', end='2015-01-01', freq='D')
window = 365 # days
num_pids = 100
probs = np.linspace(start=0.001, stop=0.1, num=num_pids)

df = pd\
    .DataFrame(
        data=[(date, pid)
              for (pid, prob) in zip(range(num_pids), probs)
              for date in np.compress(np.random.binomial(n=1, p=prob, size=len(dates)), dates)],
        columns=['Date', 'PersonId'])\
    .sort_values(by='Date')\
    .reset_index(drop=True)

print("Created data of people visiting a building:")
df.head() # 9181 rows × 2 columns

Out[2]:

Created data of people visiting a building:

|   | Date       | PersonId | 
|---|------------|----------| 
| 0 | 2010-01-01 | 76       | 
| 1 | 2010-01-01 | 63       | 
| 2 | 2010-01-01 | 89       | 
| 3 | 2010-01-01 | 81       | 
| 4 | 2010-01-01 | 7        | 

Эталон скорости

In [3]:

%%timeit
# This counts the number of people visiting the building, not the number of unique people.
# Provided as a speed reference.
df.rolling(window='{:d}D'.format(window), on='Date').count()

3.32 ms ± 124 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Тест скорости 1

In [4]:

%%timeit
df.rolling(window='{:d}D'.format(window), on='Date').apply(lambda arr: pd.Series(arr).nunique())

2.42 s ± 282 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [5]:

# Save results as a reference to check calculation accuracy.
ref = df.rolling(window='{:d}D'.format(window), on='Date').apply(lambda arr: pd.Series(arr).nunique())['PersonId'].values

Тест скорости 2

In [6]:

# Define a custom function and implement a just-in-time compiler.
@numba.jit(nopython=True)
def nunique(arr):
    return len(set(arr))

In [7]:

%%timeit
df.rolling(window='{:d}D'.format(window), on='Date').apply(nunique)

430 ms ± 31.1 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [8]:

# Check accuracy of results.
test = df.rolling(window='{:d}D'.format(window), on='Date').apply(nunique)['PersonId'].values
assert all(ref == test)

Тест скорости 3

In [9]:

# Define a custom function and implement a just-in-time compiler.
@numba.jit(nopython=True)
def windowed_nunique(dates, pids, window):
    r"""Track number of unique persons in window,
    reading through arrays only once.

    Args:
        dates (numpy.ndarray): Array of dates as number of days since epoch.
        pids (numpy.ndarray): Array of integer person identifiers.
        window (int): Width of window in units of difference of `dates`.

    Returns:
        ucts (numpy.ndarray): Array of unique counts.

    Raises:
        AssertionError: Raised if `len(dates) != len(pids)`

    Notes:
        * May be off by 1 compared to `pandas.core.window.Rolling`
            with a time series alias offset.

    """

    # Check arguments.
    assert dates.shape == pids.shape

    # Initialize counters.
    idx_min = 0
    idx_max = dates.shape[0]
    date_min = dates[idx_min]
    pid_min = pids[idx_min]
    pid_max = np.max(pids)
    pid_cts = np.zeros(pid_max, dtype=np.int64)
    pid_cts[pid_min] = 1
    uct = 1
    ucts = np.zeros(idx_max, dtype=np.int64)
    ucts[idx_min] = uct
    idx = 1

    # For each (date, person)...
    while idx < idx_max:

        # If person count went from 0 to 1, increment unique person count.
        date = dates[idx]
        pid = pids[idx]
        pid_cts[pid] += 1
        if pid_cts[pid] == 1:
            uct += 1

        # For past dates outside of window...
        while (date - date_min) > window:

            # If person count went from 1 to 0, decrement unique person count.
            pid_cts[pid_min] -= 1
            if pid_cts[pid_min] == 0:
                uct -= 1
            idx_min += 1
            date_min = dates[idx_min]
            pid_min = pids[idx_min]

        # Record unique person count.
        ucts[idx] = uct
        idx += 1

    return ucts

In [10]:

# Cast dates to integers.
df['DateEpoch'] = (df['Date'] - pd.to_datetime('1970-01-01'))/pd.to_timedelta(1, unit='D')
df['DateEpoch'] = df['DateEpoch'].astype(int)

In [11]:

%%timeit
windowed_nunique(
    dates=df['DateEpoch'].values,
    pids=df['PersonId'].values,
    window=window)

107 µs ± 63.5 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [12]:

# Check accuracy of results.
test = windowed_nunique(
    dates=df['DateEpoch'].values,
    pids=df['PersonId'].values,
    window=window)
# Note: Method may be off by 1.
assert all(np.isclose(ref, np.asarray(test), atol=1))

In [13]:

# Show where the calculation doesn't match.
print("Where reference ('ref') calculation of number of unique people doesn't match 'test':")
df['ref'] = ref
df['test'] = test
df.loc[df['ref'] != df['test']].head() # 9044 rows × 5 columns

Out[13]:

Where reference ('ref') calculation of number of unique people doesn't match 'test':

|    | Date       | PersonId | DateEpoch | ref  | test | 
|----|------------|----------|-----------|------|------| 
| 78 | 2010-01-19 | 99       | 14628     | 56.0 | 55   | 
| 79 | 2010-01-19 | 96       | 14628     | 56.0 | 55   | 
| 80 | 2010-01-19 | 88       | 14628     | 56.0 | 55   | 
| 81 | 2010-01-20 | 94       | 14629     | 56.0 | 55   | 
| 82 | 2010-01-20 | 48       | 14629     | 57.0 | 56   | 

person Samuel Harrold    schedule 28.09.2017    source источник
comment
Извините, если это глупый комментарий, но разве 365 скользящих уникальных идентификаторов не будут такими простыми, как: df.rolling(365)['PersonId'].apply(lambda x: len(set(x))) ???   -  person Woody Pride    schedule 28.09.2017
comment
@WoodyPride Спасибо, это то, что я сделал в тесте скорости 2, но с компилятором точно в срок (см. функцию nunique). Вычисление правильное, но неэффективное, так как set работает с каждым элементом в окне каждый раз, когда выполняется вычисление окна. Более эффективно вести текущий подсчет каждого элемента, как в тесте скорости 3 (эффективнее на примерных данных примерно в 4000 раз по сравнению с тестом скорости 2 и тестом скорости 3). Однако моя реализация windowed_nunique отключена на 1, и мне интересно, может ли кто-нибудь помочь найти проблему.   -  person Samuel Harrold    schedule 28.09.2017
comment
Понятно! Я не думаю, что прочитал достаточно глубоко проблему.   -  person Woody Pride    schedule 28.09.2017


Ответы (3)


У меня было 2 ошибки в быстром методе windowed_nunique, теперь исправлено в windowed_nunique_corrected ниже:

  1. Размер массива для запоминания количества уникальных счетчиков для каждого идентификатора человека в окне pid_cts был слишком мал.
  2. Поскольку передний и задний фронты окна включают целые дни, date_min следует обновлять, когда (date - date_min + 1) > window.

Ссылки по теме:

In [14]:

# Define a custom function and implement a just-in-time compiler.
@numba.jit(nopython=True)
def windowed_nunique_corrected(dates, pids, window):
    r"""Track number of unique persons in window,
    reading through arrays only once.

    Args:
        dates (numpy.ndarray): Array of dates as number of days since epoch.
        pids (numpy.ndarray): Array of integer person identifiers.
            Required: min(pids) >= 0
        window (int): Width of window in units of difference of `dates`.
            Required: window >= 1

    Returns:
        ucts (numpy.ndarray): Array of unique counts.

    Raises:
        AssertionError: Raised if not...
            * len(dates) == len(pids)
            * min(pids) >= 0
            * window >= 1

    Notes:
        * Matches `pandas.core.window.Rolling`
            with a time series alias offset.

    """

    # Check arguments.
    assert len(dates) == len(pids)
    assert np.min(pids) >= 0
    assert window >= 1

    # Initialize counters.
    idx_min = 0
    idx_max = dates.shape[0]
    date_min = dates[idx_min]
    pid_min = pids[idx_min]
    pid_max = np.max(pids) + 1
    pid_cts = np.zeros(pid_max, dtype=np.int64)
    pid_cts[pid_min] = 1
    uct = 1
    ucts = np.zeros(idx_max, dtype=np.int64)
    ucts[idx_min] = uct
    idx = 1

    # For each (date, person)...
    while idx < idx_max:

        # Lookup date, person.
        date = dates[idx]
        pid = pids[idx]

        # If person count went from 0 to 1, increment unique person count.
        pid_cts[pid] += 1
        if pid_cts[pid] == 1:
            uct += 1

        # For past dates outside of window...
        # Note: If window=3, it includes day0,day1,day2.
        while (date - date_min + 1) > window:

            # If person count went from 1 to 0, decrement unique person count.
            pid_cts[pid_min] -= 1
            if pid_cts[pid_min] == 0:
                uct -= 1
            idx_min += 1
            date_min = dates[idx_min]
            pid_min = pids[idx_min]

        # Record unique person count.
        ucts[idx] = uct
        idx += 1

    return ucts

In [15]:

# Cast dates to integers.
df['DateEpoch'] = (df['Date'] - pd.to_datetime('1970-01-01'))/pd.to_timedelta(1, unit='D')
df['DateEpoch'] = df['DateEpoch'].astype(int)

In [16]:

%%timeit
windowed_nunique_corrected(
    dates=df['DateEpoch'].values,
    pids=df['PersonId'].values,
    window=window)

98.8 µs ± 41.3 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [17]:

# Check accuracy of results.
test = windowed_nunique_corrected(
    dates=df['DateEpoch'].values,
    pids=df['PersonId'].values,
    window=window)
assert all(ref == test)
person Samuel Harrold    schedule 03.10.2017

Очень близко к вашему времени во втором тесте семян, но в качестве одного вкладыша, повторная выборка в течение года.

 df.resample('AS',on='Date')['PersonId'].expanding(0).apply(lambda x: np.unique(x).shape[0])

Результаты времени

1 loop, best of 3: 483 ms per loop
person DJK    schedule 28.09.2017
comment
Это близко к тесту скорости 2 по скорости, но np.unique работает с каждым элементом в окне. Более эффективно вести текущий подсчет каждого элемента, как в тесте скорости 3. (См. мой комментарий к Woody Pride.) Однако моя реализация текущего подсчета, windowed_nunique, отличается на 1. Любые другие мысли? Спасибо - person Samuel Harrold; 28.09.2017

Если вам нужно только количество уникальных людей, которые вошли в здание за последние 365 дней, вы можете сначала ограничить свой набор данных за последние 365 дней с помощью .loc :

df = df.loc[df['date'] > '2016-09-28',:]

а с помощью groupby вы получите столько строк, сколько уникальных людей вошли, и если вы сделаете это по подсчету, вы также получите количество раз, когда они вошли:

df = df.groupby('PersonID').count()

это, кажется, работает для вашего вопроса, но, возможно, я ошибся. хорошего дня

person Forrains_459    schedule 28.09.2017
comment
Спасибо, но я ищу эффективный скользящий уникальный подсчет. Выход должен иметь тот же len, что и вход (из примера, len(df) == len(ref) == 9181), и быть быстрее, чем тест скорости 2. - person Samuel Harrold; 28.09.2017
comment
@SamuelHarrold, что ты имеешь в виду под счет уникального количества? какой период вы перекатываете в году? - person DJK; 28.09.2017
comment
@djk47463 Пример скользящего уникального подсчета (аналогично функции nunique, определенной в тесте скорости 2 выше): df.rolling(window='365D', on='Date').apply(lambda arr: len(set(arr))). Задача состоит в том, чтобы сделать это более эффективным (сравните тест скорости 2 и тест скорости 3). Мне почти удалось, но мое решение windowed_nunique отличается на 1, и я подумал, может ли кто-нибудь найти мою ошибку. - person Samuel Harrold; 28.09.2017
comment
@ djk47463 windowed_nunique смещается на 1 в записи 78 (из Out[13] в примере), которая имеет соответствующую дату «2010-01-19», то есть до любого дополнительного високосного дня. - person Samuel Harrold; 28.09.2017