Обработка временных серий NDVI

Задача. Рассматриваются временные ряды NDVI. Эти данные обычно содержат шумовую компоненту, которая появляется вследствие влияния облачности, состояния атмосферы и т.п. Поэтому если построить график зависимости NDVI от времени, получится не красивая гладкая кривая, а сложная ломаная линия, в которой найти закономерность довольно сложно. Поэтому возникает задача очистки рядов NDVI от шумов.

Как читать. Начать стоит с раздела Теории.

Следующий за теорией раздел Реализация вначале содержит подраздел, в котором рассматривается вопрос о программной реализации фильтра, этот подраздел содержит много технических деталей, но может быть важен для понимания того, как функционирует описанная система. Последняя часть раздела "Реализация" содержит пошаговое выполнение алгоритма, сопровождаемое выводом получаемой на каждом шаге информации. Думаю, что для ознакомления с методом стоит прочитать этот раздел сразу после раздела теории, а технические детали оставить на потом.

Раздел Автоматизация содержит функции, готовые для использования в скриптах.

В конце находится раздел, посвященный исследованию поведения алгоритма на модельных данных и раздел, в котором обрабатываются реальные данные NDVI.

Теория

Методов обработки зашумленных данных NDVI много, здесь за основу процедуры взята статья: Chen, Jin, et al. "A simple method for reconstructing a high-quality NDVI time-series data set based on the Savitzky–Golay filter." Remote sensing of Environment 91.3 (2004) 332-344..

Принцип работы

В основе процедуры лежит фильтр Савицкого-Голая (Savitzky–Golay), применяемый для сглаживания изменяющихся во времени сигналов. Суть его состоит в том, что в скользящем окне шириной $2m+1$ исходная кривая аппроксимируется полиномом степени $d$ (например, параболой: $d=2$), коэффициенты которого расчитываются тем или иным способом (например, методом наименьших квадратов). Для центральной точки окна по этому полиному вычисляется значение, которое и будет результирующим значением отфильтрованной кривой в данной точке. Затем окно сдвигается на одну позицию и строится новая точка --- прогнозное значение кривой. Процедура повторяется до тех пор, пока не закончится исходный ряд.

Если же говорить об обработке серий NDVI, то у них есть особенности поведения, которые стоит учитывать. Одна из основных особенностей состоит в том, что шумовая компонента (возникающая вследствие облачности и других атмосферных явлений) на участках, покрытых растительностью, обычно понижает значение NDVI. Поэтому при фильтрации данных стоит находить кривую, огибающую график временной серии NDVI, по верхней границе, а не проходящую по центру серии.

Обозначения: дан временной ряд $(t_i, N_i)$, где $t_i$ --- дата $N_i$ --- значение NDVI в момент $t_i$. Дополнительно к $N_i$ у исследователя может быть ряд $F_i$ --- маска облачности в момент $t_i$.

Согласно статье обработка происходит по следующему алгоритму:

  1. Линейная интерполяция значений NDVI для закрытых облаками пикселей. Многие данные по NDVI сопровождаются масками облачности, и такие замаскированные значения можно (нужно?) выбросить из анализа и заменяють прогнозными. Прогнозные значения строятся на базе линейной интерполяции между ближайшими незамаскированными значениями NDVI. Кроме этого всплески значений NDVI, которые превышают 0.4 единицы и которые произошли в пределах 20 дней, считаются неоправданно высокими (необычными для растительности) и отбраковываются так же, как и покрытые облаками. На выходе данного этапа получаем новый временной ряд $(t_i, N_i^0)$.
  2. Поиск тренда фильтром Савицкого-Голая. При фильтрации данных нужно принимать во внимание два условия, которые должны выполняться для результата фильтрации: (1) итоговая кривая должна отражать основные особенности поведения серии значений NDVI без значительных потерь временного разрешения (2) поскольку шумы, вносимые облачностью и др. атмосферными явлениями обычно понижают значения NDVI, то большая часть таких шумовых точек должна находиться ниже линии тренда. На вход процедуры фильтрации подаются значения $(t_i, N_i^0)$ и строится ряд $(t_i, N_i^{tr})$.
  3. Определение весового коэффициента для каждой точки временного ряда. На базе временных рядов $(t_i, N_i^0)$ и $(t_i, N_i^{tr})$ вычислим весовые коэффициенты точек. Эти коэффициэнты будут использованы в дальнейшем при оценке качества подгонки кривой. Идея в назначении весов следующая: если $N_i^0$ лежит ниже, чем $N_i^{tr}$, то предполагаем, что это зашумленная точка, а если $N_i^0$ лежит выше $N_i^{tr}$, то эта точка предположительно отражает реальное значение NDVI. Поэтому назначим веса следующим образом: $$ W_i = \begin{cases} 1, & \text{если } N_i^0\geq N_i^{tr}\\ 1 - d_i/d_{max}, & \text{иначе}, \end{cases} $$ где $d_i = |N_i - N_i^{tr}|$, $d_{max}$ --- наибольшее из всех $d_i$.
  4. Генерация нового ряда значений NDVI. На этом шаге у нас есть исходный ряд, тренд и список зашумленных точек. Мы можем все зашумленные точки заменить на точки, взятые из тренда. Таким образом будет построен новый ряд $(t_i, N_i^1)$: $$ N_i^1 = \begin{cases} N_i^0, & \text{если } N_i^0\geq N_i^{tr}\\ N_i^{tr}, & \text{иначе} \end{cases} $$
  5. Фильтрация нового временного ряда NDVI (подгонка кривой). Применим еще раз фильтр, но на этот раз к ряду $(t_i, N_i^1)$. Кроме того, поскольку этот ряд менее зашумлен, чем исходный, уменьшим размер окна $m$ и/или увеличим степень полинома $d$. На выходе получаем новый ряд $(t_i, N_i^{k+1})$ ($k$ --- номер итерации).
  6. Определение качества подгонки кривой. Качество подгонки на $k$-й итерации обозначим $F_k$ и будем рассчитывать по формуле: $$ F_k = \sum_{i=1}^n |N_i - N_i^{k+1}|\times W_i, $$ где веса были расчитаны на шаге 3. Из этой формулы видно, что чем меньше значение $F_k$, тем больше ряд $(t_i, N_i^{k+1})$ приближается к верхней границе значений NDVI исходного ряда.
  7. Оценка необходимости проведения еще одной итерации подгонки. Если необхоимость есть, переход на шаг номер 4. Если нет, то завершение алгоритма. Авторы статьи утверждают, что у последовательности $F_k$ есть минимум -- сначала значения снижаются, затем, начиная с некоторого момента начинают возрастать. Поэтому условие завершения цикла выглядит так: $F_{k-1} \geq F_k \leq F_{k+1}$. Временной ряд $(t_i, N_i^{k+1})$, соответствующий минимальному значению $F_k$, и есть искомый ряд.

Реализация

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

Импортируем необходимые библиотеки и создадим основыне функции, которые понадобятся в дальнейшем:


In [1]:
import datetime

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


from tools import get_ndvi_frame
from tools import get_modis_ndvi_frame

Чтение данных NDVI


In [2]:
frame = get_ndvi_frame('data/Landsat/point_1_band3.csv', 'data/Landsat/point_1_band4.csv')
frame.head()


Out[2]:
Year Day B3 B4 NDVI DayNum
toar_LT51610161984127KIS00 1984 127 0.653173 0.618082 -0.027603 724402
toar_LT51590161984129KIS00 1984 129 0.577255 0.540409 -0.032967 724404
toar_LT51600161984184XXX05 1984 184 0.075905 0.285286 0.579696 724459
toar_LT51610161985065KIS00 1985 65 0.474825 0.523001 0.048281 724706
toar_LT51590161985067KIS00 1985 67 0.588754 0.646601 0.046826 724708

Построим график NDVI


In [3]:
plt.plot(frame.DayNum, frame.NDVI)


Out[3]:
[<matplotlib.lines.Line2D at 0x7fab219f7890>]

График перегружен информацией. Построим график за 1986-1986 год, чтобы рассмотреть некоторые детали.


In [4]:
selection = frame.ix[(frame.Year==1986)| (frame.Year==1987)]
print selection.head()

# Подготовим метки для показа на графике
# (отложим по оси абсцисс не все дни, иначе
# среди меток будет каша)
size = len(selection)
indexes = range(0, size, 2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()


                            Year  Day        B3        B4      NDVI  DayNum
toar_LT51610161986084KIS00  1986   84  0.667220  0.713723  0.033675  725090
toar_LT51590161986086KIS00  1986   86  0.668508  0.684378  0.011730  725092
toar_LT51610161986100KIS00  1986  100  0.499498  0.552594  0.050467  725106
toar_LT51590161986102KIS00  1986  102  0.745292  0.820260  0.047886  725108
toar_LT51610161986116KIS00  1986  116  0.771071  0.808966  0.023983  725122

Из этого графика более-менее видно, как ведет себя индекс в течении пары лет. В дальнейшем для примера будем работать с этой выборкой.

Необходимые функции

Построим фильтр на базе фильтра Савицкого-Голая. В scipy.signal реализован этот фильтр, но, к сожалению, в нем предполагается, что сигнал задан через равные интервалы (см. исходный код фильтра). Таким образом, если пользоваться готовым фильтром, то мы должны предварительно произвести интерполяцию NDVI так, чтобы временной ряд был равноотстоящим. Можно модифицировать код так, чтобы он мог работать с неполными (неравноотстоящими рядами). Проблема такого подхода в том, вычислительная сложность значительно возрастает.

Пока решил идти вторым путем. За основу функции взят исходный код http://wiki.scipy.org/Cookbook/SavitzkyGolay и модифицирован для работы неполными рядами.

Исходный код был такой (с небольшими упрощениями кода -- выкинул дифференцирование, которое нам не нужно):


In [5]:
def savitzky_golay(y, window_size, order):
    r"""Smooth data with a Savitzky-Golay filter.
    Parameters
    ----------
    y : array_like, shape (N,)
        the values of the time history of the signal.
    window_size : int
        the length of the window. Must be an odd integer number.
    order : int
        the order of the polynomial used in the filtering.
        Must be less then `window_size` - 1.
    Returns
    -------
    ys : ndarray, shape (N)
        the smoothed signal.
    """
    try:
        window_size = np.abs(np.int(window_size))
        order = np.abs(np.int(order))
    except ValueError, msg:
        raise ValueError("window_size and order have to be of type int")
    if window_size % 2 != 1 or window_size < 1:
        raise TypeError("window_size size must be a positive odd number")
    if window_size < order + 2:
        raise TypeError("window_size is too small for the polynomials order")
    order_range = range(order+1)
    half_window = (window_size -1) // 2
    # precompute coefficients
    b = np.mat([[k**i for i in order_range] for k in range(-half_window, half_window+1)])
    m = np.linalg.pinv(b).A[0]
    # pad the signal at the extremes with
    # values taken from the signal itself
    firstvals = y[0] - np.abs( y[1:half_window+1][::-1] - y[0] )
    lastvals = y[-1] + np.abs(y[-half_window-1:-1][::-1] - y[-1])
    y = np.concatenate((firstvals, y, lastvals))
    return np.convolve( m[::-1], y, mode='valid')

Создадим функцию, которая реализует процедуру для неполных рядов:


In [6]:
def savitzky_golay2(t, y, window_size, order):
    r"""Smooth data with a Savitzky-Golay filter.
    Parameters
    ----------
    t : array_like, shape (N,)
        the values of the time.
    y : array_like, shape (N,)
        the values of the time history of the signal.
    window_size : int
        the length of the window. Must be an odd integer number.
    order : int
        the order of the polynomial used in the filtering.
        Must be less then `window_size` - 1.
    Returns
    -------
    ys : ndarray, shape (N)
        the smoothed signal.
    """
    try:
        window_size = np.abs(np.int(window_size))
        order = np.abs(np.int(order))
    except ValueError, msg:
        raise ValueError("window_size and order have to be of type int")
    if window_size % 2 != 1 or window_size < 1:
        raise TypeError("window_size size must be a positive odd number")
    if window_size < order + 2:
        raise TypeError("window_size is too small for the polynomials order")
    
    order_range = range(order+1)
    half_window = (window_size -1) // 2
    
    y = np.array(y)  # It prevents caveats in pandas' indexing 
    
    # pad the signal at the extremes with
    # values taken from the signal itself
    firstvals = y[0] - np.abs( y[1:half_window+1][::-1] - y[0] )
    lastvals = y[-1] + np.abs(y[-half_window-1:-1][::-1] - y[-1])
    y = np.concatenate((firstvals, y, lastvals))
    
    # Store the result
    result = y.copy()
    
    # Store distances between ticks
    spaces = np.diff(t)
    # copy first and last parst of 'spaces', to fit the lengths of 'spaces' and 'y' arrays
    spaces = np.concatenate((spaces[:half_window], spaces, spaces[-half_window:]))
    
    # precompute coefficients
    for begin in range(len(y) - window_size):
        # Running window
        win = spaces[begin : begin + window_size].copy()
        
        # Calculate the distances between the center point and the ticks:
        win[half_window] = 0   # The center of the windows
        first_half = win[:half_window].copy()
        second_half = win[half_window:]
        
        win[half_window:] = np.cumsum(second_half)  # Distances to the right
        win[:half_window] = -first_half[::-1].cumsum()[::-1]  # Distances to the left
        
        # Ordinary Least Squares:
        b = np.mat([[k**i for i in order_range] for k in win])
        m = np.linalg.pinv(b).A[0]
        
        result[begin+half_window] =  np.convolve( m[::-1], y[begin: begin + window_size], mode='valid')
        
    return result[half_window: len(y) - half_window]

Проверим работу: создадим сигнал (с равными отсчетами) и прогоним его через обе функции. Для равных отсчетов результат должен быть одинаковым


In [7]:
order = 3
window_size = 7

t = np.arange(12)
y = t**2 + 10*np.random.randn(len(t))

print savitzky_golay(y, window_size, order)
print savitzky_golay2(t, y, window_size, order)


[  -1.12266391    8.55235744   14.70771382   20.30371839   24.44664906
   31.41656847   41.95010531   50.16059845   62.55946989   78.92577626
   88.35679681  110.39833697]
[  -1.12266391    8.55235744   14.70771382   20.30371839   24.44664906
   31.41656847   41.95010531   50.16059845   62.55946989   78.92577626
   88.35679681  110.39833697]

Одна итерация алгоритма

Шаг 1. Линейная интерполяция

Данные по NDVI были импортированы в таблицу frame:


In [8]:
frame.head()


Out[8]:
Year Day B3 B4 NDVI DayNum
toar_LT51610161984127KIS00 1984 127 0.653173 0.618082 -0.027603 724402
toar_LT51590161984129KIS00 1984 129 0.577255 0.540409 -0.032967 724404
toar_LT51600161984184XXX05 1984 184 0.075905 0.285286 0.579696 724459
toar_LT51610161985065KIS00 1985 65 0.474825 0.523001 0.048281 724706
toar_LT51590161985067KIS00 1985 67 0.588754 0.646601 0.046826 724708

Согласно используемой статье, необходимо произвести интерполяцию значений NDVI за те даты, которые приходились на облачную погоду. Но во-первых, маски облачности у нас нет. А во-вторых, судя по всему, в статье описывался подход, когда фильтр работал с равными интервалами (а у нас есть фильтр savitzky_golay2, способный работать с неполными рядами).

Учитывая все сказанное, пропустим этот шаг.

Шаг 2. Поиск тренда

Важно выбрать правильное значение $m$ --- ширины окна, в котором будем производить подгонку полинома под наши данные. Слишком маленькое значение приведет к тому, что в результирующей кривой останется излишинее количество деталей, но если выбрать большое $m$, то сглаживание будет избыточным. Авторы статьи рекомендуют начинать со значений $m$, равных 4--7. Аналогично со степенью полинома $d$. Попробуем начать с параболы (2-я степень) и постепенно увеличим до 4-й степени.

Таким образом "хорошая" пара $(m, d)$ подбирается экспериментально.

Будем анализировать не все данные, а только за пару лет. Произведем выборку и построим график NDVI.


In [9]:
selection = frame.ix[(frame.Year==1986)| (frame.Year==1987)].copy()

# Подготовим метки для показа на графике
# (отложим по оси абсцисс не все дни, иначе
# среди меток будет каша)
size = len(selection)
indexes = range(0, size, 2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI, label='NDVI')
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.legend(loc=2)
plt.show()


Прогоним через фильтр. Заметим, что в статье ширина окна измеряется в "половинках", а в функции savitzky_golay2 в "целых". Т.е. рекомендуемые авторами 4--7 единиц ширины означает 9-15 единиц в терминах функции savitzky_golay2. Попробуем начать с ширины равной 9-ти и порядка $d=2$


In [10]:
trend9 = savitzky_golay2(selection.DayNum, selection.NDVI, window_size=9, order=2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI, label='NDVI')
plt.plot(selection.DayNum, trend9, label='Trend')
plt.legend(loc=2)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()


Возьмем окно в 15 отсчетов:


In [11]:
trend15 = savitzky_golay2(selection.DayNum, selection.NDVI, window_size=15, order=2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI, label='NDVI')
plt.plot(selection.DayNum, trend15, label='Trend')
plt.legend(loc=2)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()


Лично мне в качестве первого прибилижения тренд, построенный на 15-ти отсчетах, нравится больше. Возьму за основу его.


In [12]:
trend = trend15

Шаг 3. Расчет весовых коэффициентов.

На предыдущем шаге расчитали линию тренда. Найдем веса для точек по формуле $$ W_i = \begin{cases} 1, & \text{если } N_i\geq N_i^{tr}\\ 1 - d_i/d_{max}, & \text{иначе}, \end{cases} $$ где $d_i = |N_i - N_i^{tr}|$, $d_{max}$ --- наибольшее из всех $d_i$.


In [13]:
d = np.abs(selection.NDVI - trend)
dmax = np.max(d)
print 'dmax =', dmax


dmax = 0.423669347522

Для удобства дальнейшей работы добавим в таблицу selection еще две колонки: колонку, содержащую тренд, и колонку, содержащую веса.


In [14]:
selection['Trend'] = trend
print selection.head()

def get_w(row, dmax=dmax):
    # Расчет весов для строки row
    result = 1 if row['NDVI'] >= row['Trend'] else 1 - abs(row['NDVI']- row['Trend'])/dmax
    return result

selection['W'] = selection.apply(get_w, axis=1)
print selection.head()


                            Year  Day        B3        B4      NDVI  DayNum  \
toar_LT51610161986084KIS00  1986   84  0.667220  0.713723  0.033675  725090   
toar_LT51590161986086KIS00  1986   86  0.668508  0.684378  0.011730  725092   
toar_LT51610161986100KIS00  1986  100  0.499498  0.552594  0.050467  725106   
toar_LT51590161986102KIS00  1986  102  0.745292  0.820260  0.047886  725108   
toar_LT51610161986116KIS00  1986  116  0.771071  0.808966  0.023983  725122   

                               Trend  
toar_LT51610161986084KIS00  0.027183  
toar_LT51590161986086KIS00  0.016859  
toar_LT51610161986100KIS00  0.008991  
toar_LT51590161986102KIS00  0.000892  
toar_LT51610161986116KIS00 -0.008708  
                            Year  Day        B3        B4      NDVI  DayNum  \
toar_LT51610161986084KIS00  1986   84  0.667220  0.713723  0.033675  725090   
toar_LT51590161986086KIS00  1986   86  0.668508  0.684378  0.011730  725092   
toar_LT51610161986100KIS00  1986  100  0.499498  0.552594  0.050467  725106   
toar_LT51590161986102KIS00  1986  102  0.745292  0.820260  0.047886  725108   
toar_LT51610161986116KIS00  1986  116  0.771071  0.808966  0.023983  725122   

                               Trend         W  
toar_LT51610161986084KIS00  0.027183  1.000000  
toar_LT51590161986086KIS00  0.016859  0.987893  
toar_LT51610161986100KIS00  0.008991  1.000000  
toar_LT51590161986102KIS00  0.000892  1.000000  
toar_LT51610161986116KIS00 -0.008708  1.000000  

Шаг 4. Генерация нового ряда.

Добавим в таблицу еще одну колонку, в которой будем хранить прогнозные данные NDVI, расчитанные по формуле $$ N_i^1 = \begin{cases} N_i, & \text{если } N_i\geq N_i^{tr}\\ N_i^{tr}, & \text{иначе} \end{cases} $$


In [15]:
def get_n(row):
    # Функция, в которой закодирована формула
    result = row['NDVI'] if row['NDVI'] >= row['Trend'] else row['Trend']
    return result

selection['N'] = selection.apply(get_n, axis=1)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI, label='NDVI')
plt.plot(selection.DayNum, selection.N, label='New')
plt.legend(loc=2)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()


Шаг 5. Повторная фильтрация.

Уменьшим ширину окна и повторим фильтрацию, используя прогнозные значения NDVI:


In [16]:
trend = savitzky_golay2(selection.DayNum, selection.N, window_size=13, order=2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI, label='NDVI')
plt.plot(selection.DayNum, selection.Trend, label='Old Trend')
plt.plot(selection.DayNum, trend, label='New Trend')
plt.legend(loc=2)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()

selection.Trend = trend


Новый ряд действительно проходит выше, чем предыдущий.

Шаг 6. Оценка качества подгонки.

Добавим еще одну вспомогательную колонку, в которой будем хранить величины $|N_i - N_i^{k+1}|\times W_i$, а затем просуммируем полученные значения и найдем оценку качества: $$ F_k = \sum_{i=1}^n |N_i - N_i^{k+1}|\times W_i $$


In [17]:
selection['F'] = abs(selection['NDVI'] - selection['N'])*selection['W']
print selection.head()
f1 = selection.F.sum()
print 'F1 =', f1


                            Year  Day        B3        B4      NDVI  DayNum  \
toar_LT51610161986084KIS00  1986   84  0.667220  0.713723  0.033675  725090   
toar_LT51590161986086KIS00  1986   86  0.668508  0.684378  0.011730  725092   
toar_LT51610161986100KIS00  1986  100  0.499498  0.552594  0.050467  725106   
toar_LT51590161986102KIS00  1986  102  0.745292  0.820260  0.047886  725108   
toar_LT51610161986116KIS00  1986  116  0.771071  0.808966  0.023983  725122   

                               Trend         W         N         F  
toar_LT51610161986084KIS00  0.026333  1.000000  0.033675  0.000000  
toar_LT51590161986086KIS00  0.026598  0.987893  0.016859  0.005067  
toar_LT51610161986100KIS00  0.024627  1.000000  0.050467  0.000000  
toar_LT51590161986102KIS00  0.048731  1.000000  0.047886  0.000000  
toar_LT51610161986116KIS00  0.041343  1.000000  0.023983  0.000000  
F1 = 1.87127847399

Шаг 7. Циклическая обработка.

Далее процесс нужно зациклить, перейдя на шаг 4.

Автоматизация.

В предыдущих разделах подробно рассматривался пошаговый процесс импорта данных и анализа. В данном разделе собраны функции, которые автоматизируют эти этапы и которые могут быть использованы в скриптах.

Фукнкция фильтрации была определена выше.

Алгоритм, описанный в статье представлен в виде функции. На входе ожидается фрейм данных в формате, возвращаемом функцией импорта. Т.е. там жестко зашиты названия колонок (можно от этого избавиться, но, думаю, пока это не важно --- все равно итоговый модуль будет иным).


In [18]:
def fit_ndvi(frame, window_size=15, order=2):
    '''Chen, Jin, et al. "A simple method for reconstructing a 
    high-quality NDVI time-series data set based on the 
    Savitzky–Golay filter." 
    Remote sensing of Environment 91.3 (2004) 332-344.
    '''
    def get_w(row, dmax):
        # Расчет весов для строки row
        result = 1 if row['NDVI'] >= row['Trend'] else 1 - abs(row['NDVI']- row['Trend'])/dmax
        return result
    
    def get_n(row):
        # Функция, в которой закодирована формула
        result = row['NDVI'] if row['NDVI'] >= row['N'] else row['N']
        return result
        
    # step 2
    trend = savitzky_golay2(frame.DayNum, frame.NDVI, window_size=window_size, order=order)
    frame['Trend'] = trend 
    
    # step 3
    d = np.abs(frame.NDVI - trend)
    dmax = np.max(d)
    
    frame['W'] = frame.apply(get_w, axis=1, args=(dmax,))
    
    iteration = 1
    Old_F = 1000000   # A big number
    Current_F = Old_F - 1
    frame['N'] = frame['NDVI']
    while Current_F < Old_F:
        print 'iteration:', iteration, 'Current_F', Current_F
        
        # step 4
        frame['N'] = frame.apply(get_n, axis=1)
    
        # step 5 
        # decrease window_size by t
        t = min(8, 2 * iteration) # Just a test
        trend = savitzky_golay2(frame.DayNum, frame.N, window_size=window_size-t, order=2)
        frame.N = trend
    
        frame['F'] = abs(frame['NDVI'] - frame['N'])*frame['W']
        Old_F = Current_F
        Current_F = frame.F.sum()
        print 'New Current_F is assigned to', Current_F
        
        iteration += 1
    return frame

Исследование работы алгоритма.

В этом разделе посмотрим, как ведет себя алгоритм на модельных данных. Для этого:

  1. Сгенерируем кривую, приблизительно напоминающую "идеальную" NDVI.
  2. Изменим кривую, добавив шумов и удалив часть данных.
  3. Запустим рассматриваемый алгоритм и посмотрим на особенности его поведения.
  4. Повторим процедуру, начиная со второго шага с другими искажениями.

Генерация модельных кривых.

Создадим функцию, генерирующую кривую, напоминающую NDVI:


In [19]:
def get_signal(years=2):
    T = years * 365  # Число дней, которые мы будем анализировать
    bias = 40
    day = np.arange(bias, T + bias, 10) # возьмем не все, а шагом в 10 дней
    
    # Не важно, какая формула, главное чтобы было "похоже"
    ndvi = np.maximum(np.sin(2*years*pi*day/T - bias) + 0.7, 0)
    ndvi = (ndvi)**(0.3)
    ndvi = ndvi/np.max(ndvi)
    
    return pd.DataFrame(data = {'DayNum': day, 'NDVI': ndvi})

frame = get_signal()
plot(frame.DayNum, frame.NDVI)


Out[19]:
[<matplotlib.lines.Line2D at 0x7fab21a18e50>]

Создадим функцию, которая будет зашумлять сигнал и частично удалять данные из него. Функция принимает параметр drop_perc -- процент точек, которые будут удалены из сигнала, параметр noise_perc -- процент точек, которые будут зашумлены, и параметр noise_level --- значение, которому будет приравнена шумовая часть сигнала.


In [20]:
def get_noisy_signal(drop_perc=50, noise_perc=50, noise_level=0.3, years=2):
    np.random.seed(0)
    
    signal = get_signal(years=years)
    
    size = len(signal)
    all_index = signal.index.tolist()
    
    noisy_count = int(size*noise_perc/100)
    noisy_index = np.random.choice(all_index, size=noisy_count)
    signal.NDVI[noisy_index] = noise_level + np.random.randn(noisy_count)/10
    
    drop_count = int(size*drop_perc/100)
    drop_index = np.random.choice(all_index, size=drop_count)
    signal.NDVI[drop_index] = nan
    
    return signal.dropna()


frame = get_noisy_signal()
plot(frame.DayNum, frame.NDVI)


-c:11: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
-c:15: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame

See the the caveats in the documentation: http://pandas.pydata.org/pandas-docs/stable/indexing.html#indexing-view-versus-copy
Out[20]:
[<matplotlib.lines.Line2D at 0x7fab217f4b90>]

Обработка зашумленных сигналов

Прогоним через алгоритм слабозашумленный сигнал.


In [21]:
frame = get_noisy_signal(drop_perc=10, noise_perc=10)
plot(frame.DayNum, frame.NDVI)


Out[21]:
[<matplotlib.lines.Line2D at 0x7fab215c4a90>]

In [22]:
frame = get_noisy_signal(drop_perc=10, noise_perc=10)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 3.74176008853
iteration: 2 Current_F 3.74176008853
New Current_F is assigned to 2.60933715959
iteration: 3 Current_F 2.60933715959
New Current_F is assigned to 2.48032535817
iteration: 4 Current_F 2.48032535817
New Current_F is assigned to 2.47457739945
iteration: 5 Current_F 2.47457739945
New Current_F is assigned to 2.49743382314

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

Возьмем сигнал более сложный:


In [23]:
frame = get_noisy_signal(drop_perc=25, noise_perc=25)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 4.69501801773
iteration: 2 Current_F 4.69501801773
New Current_F is assigned to 3.13924735854
iteration: 3 Current_F 3.13924735854
New Current_F is assigned to 2.97881038917
iteration: 4 Current_F 2.97881038917
New Current_F is assigned to 2.85163880556
iteration: 5 Current_F 2.85163880556
New Current_F is assigned to 2.94704674137

Здесь "горбы" исходной кривой восстановлены довольно точно. Но в промежутке между горбами отфильтрованная кривая задралась вверх. Частично это явление мы видили еще на предыдушем рисунке, а частично на него повлияло то, что на этом участке появилось несколько шумовых точек, повышающих уровень исходного сигнала. Поскольку в алгоритм заложена процедура "подтягивания" сглаженного сигнала к точкам наибольшего уровня, то видим, что в итоге промежуток между горбами поднялся с нуля до 0.35--0.4 единиц.

Усилим зашумление еще больше:


In [24]:
frame = get_noisy_signal(drop_perc=50, noise_perc=50)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 6.29414208455
iteration: 2 Current_F 6.29414208455
New Current_F is assigned to 4.34813727425
iteration: 3 Current_F 4.34813727425
New Current_F is assigned to 3.9928349649
iteration: 4 Current_F 3.9928349649
New Current_F is assigned to 3.79943898365
iteration: 5 Current_F 3.79943898365
New Current_F is assigned to 3.79228624997
iteration: 6 Current_F 3.79228624997
New Current_F is assigned to 3.83934657104

На этом рисунке ничего принципиально нового не появляется: видим, что горбы восстановлены с достаточной точностью (хотя погрешность, конечно, возрасла), а промежуток между ними оказался излишне задранным вверх (в большей степени, чем на предыдущем рисунке).

И, наконец, чтобы поиздеваться вволю над машиной, подадим сплошной шум:


In [25]:
frame = get_noisy_signal(drop_perc=70, noise_perc=70)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 4.85211142541
iteration: 2 Current_F 4.85211142541
New Current_F is assigned to 4.14176555849
iteration: 3 Current_F 4.14176555849
New Current_F is assigned to 4.07130266329
iteration: 4 Current_F 4.07130266329
New Current_F is assigned to 3.62280818758
iteration: 5 Current_F 3.62280818758
New Current_F is assigned to 3.61786352827
iteration: 6 Current_F 3.61786352827
New Current_F is assigned to 3.69701705148

Попробуем посмотреть на "многолетние" циклы:


In [26]:
frame = get_noisy_signal(drop_perc=10, noise_perc=10, years=8)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 18.5349014321
iteration: 2 Current_F 18.5349014321
New Current_F is assigned to 12.3394646861
iteration: 3 Current_F 12.3394646861
New Current_F is assigned to 11.2782570416
iteration: 4 Current_F 11.2782570416
New Current_F is assigned to 11.0958306858
iteration: 5 Current_F 11.0958306858
New Current_F is assigned to 11.2292797341

Слабо зашумленный сигнал восстанавливается вполне приемлемо. Проблемы все те же, что и замеченные ранее, новых не добавилось.

Добавим шумов:


In [27]:
frame = get_noisy_signal(drop_perc=50, noise_perc=50, years=8)
filtered = fit_ndvi(frame)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 26.670890007
iteration: 2 Current_F 26.670890007
New Current_F is assigned to 21.4434643109
iteration: 3 Current_F 21.4434643109
New Current_F is assigned to 20.5006150199
iteration: 4 Current_F 20.5006150199
New Current_F is assigned to 19.6435210892
iteration: 5 Current_F 19.6435210892
New Current_F is assigned to 19.829830249

Общий вывод по модельным данным

В целом восстановление сигнала на модельных данных идет удовлетворительно.

Основная замеченная проблема: завышение уровня сигнала на участках с низким NDVI. В случае низкого NDVI (зимнее время, участки не покрытые растительностью) весьма вероятно появления точек со значениями, превышающими "нормальный" уровень NDVI. В этих случаях процедура будет завышать показания.

Уменьшить эту проблему можно, если каким-либо образом оценить, является ли данная точка шумовой или нет. Видимо, один из самых простых способов --- использовать маску облачности (как и было рекомендовано в исходной статье).

Обработка реальных данных

Прогоним алгоритм на двух типах данных: на данных Landsat и данных MODIS.

Данные по Landsat

Данные Landsat возьмем не за весь период, а за несколько смежных лет. (При этом заметим, что не вся информация за зимний период есть в наличии).


In [28]:
frame = get_ndvi_frame('data/Landsat/point_1_band3.csv', 'data/Landsat/point_1_band4.csv')

selection = frame.ix[(frame.Year==1985) | (frame.Year==1986)| (frame.Year==1987) | (frame.Year==1988)].copy()

# Подготовим метки для показа на графике
size = len(selection)
indexes = range(0, size, 2)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(selection.DayNum, selection.NDVI)
plt.xticks(selection.DayNum[indexes], selection.Day[indexes], rotation='vertical')
plt.show()



In [29]:
filtered = fit_ndvi(selection)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 10.9379584728
iteration: 2 Current_F 10.9379584728
New Current_F is assigned to 9.00497102961
iteration: 3 Current_F 9.00497102961
New Current_F is assigned to 8.74032421903
iteration: 4 Current_F 8.74032421903
New Current_F is assigned to 8.64425459924
iteration: 5 Current_F 8.64425459924
New Current_F is assigned to 8.91368939469

Вывод по Landsat

Основные проблемы алгоритма, которые были замечены на модельных данных никуда не изчезли. Алгоритм все так же завышает значения в зимний период. В тех случаях, когда зимних данных мало, и алгоритму по сути нечего завышать, мы видим почти линейную интерполяцию. Если данные за зимний период есть, то они результат пройдет по верхней (в данном случае шумовой) границе значений.

MODIS

В качестве входных данных возьмем MOD13 --- 16-ти дневный продукт. Этот продукт содержит информацию о качестве полученного значения NDVI, которую можно учитывать в алгоритме фильтрации при назначении весов. Но пока будем использовать алгоритм "как есть", без адаптации.


In [30]:
datafile = 'data/MODIS/point_5_NDVI.csv'
frame = get_modis_ndvi_frame(datafile)
plot(frame.DayNum, frame.NDVI)


Out[30]:
[<matplotlib.lines.Line2D at 0x7fab202c2f10>]

In [31]:
filtered = fit_ndvi(frame.copy())

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(filtered.DayNum, filtered.NDVI, 'ro--', label='Noisy Points')
plt.plot(filtered.DayNum, filtered.N, label='Filtered')
plt.legend(loc=2)
plt.show()


iteration: 1 Current_F 999999
New Current_F is assigned to 52852
iteration: 2 Current_F 52852
New Current_F is assigned to 48193
iteration: 3 Current_F 48193
New Current_F is assigned to 48620

Вывод по MODIS

За счет более гладких (и более равномерно распределенных) входных данных MODIS алгоритм фильтрации работает значительно лучше, чем на Landsat. Тем не менее, на графиках отчетливо видно, что этот алгоритм "удлинняет" вегетативный период -- в осеннее и весеннее время отфильтрованные значения изменяются менее резко, чем реальные данные. Летний же период восстанавливается относительно нормально.