Сегментация данных ДЗЗ по временным сериям NDVI на базе преобразования Фурье в GRASS GIS

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

В данной статье рассматривается разведочный анализ на данных NDVI, из продукта MOD13 (территория в районе Ханты-Мансийска).


In [1]:
# coding=utf-8
import os
import sys

import numpy as np
import pandas as pd

import matplotlib.pyplot as plt
import matplotlib as mpl

from FFT_NDVI_Segmentation_GRASS import GRASS

Перед началом работы нужно обязательно инициализировать GRASS, для этого нужно передать путь к каталогу, в который установлен GRASS, путь к каталогу БД GRASS, а также название MAPSET.


In [2]:
grass = GRASS('/opt/grass70-svn', '/home/dima/GIS/GRASSDATA', 'ugra')

Информация по текущему региону:


In [3]:
data = grass.ndvi_as_array()

Выбор коэффициентов разложения


In [4]:
from scipy.fftpack import fft, ifft, fftshift, fftfreq

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

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

  1. Разбиваем наши данные на обучающее и тестовое множество.
  2. Выбираем число $N$ -- количество коэффициентов, которые включаем в анализ ($N$ меньше, чем общее число гармоник).
  3. $N$ гармоник с наибольшей энергией оставляем, коэффициенты остальных гармоник обнуляем.
  4. Восстанавливаем сигнал.
  5. Находим погрешность восстановленного сигнала, сравнивая его с ожидаемыми значениями сигналов. Эту проверку производим на тестовом множестве.
  6. Число $N$, которое позволит восстановить сигнал с наименьшей погрешностью и будет использоваться в дальнейшем.

Самое интересное происходит в пятом пункте -- выбор функции оценки погрешности.

Функция фильтрации

Построим для удобства работы вспомогательную функцию, которая будет производить шаги 2-- 4. Будем рассматривать пиксели растров по отдельности (т.е. произведем одномерное преобразование Фурье). Таким образом после преобразования Фурье у нас будет трехмерных массив комплексных чисел -- первые два измерения отвечают за широту/долготу пикселя, а третье измерение -- коэффициенты разложения.


In [5]:
def get_max_coef_idx(data_fft, N, return_sym=True):
    """Возвращает индексы у N наиболее значимых гармоник.
    Если return_sym=True, то возвращать все коэффициенты (с учетом симметрии),
    как с положительными, так и отрицательными частотами. (Т.е. 1 + 2(N-1) коэффициентов )
    """
    data_fft_power = abs(data_fft)**2  # энергетический спектр
    
    size = data_fft_power.shape[-1]
    half = 1 + size/2
    
    # индексы N наибольших по модулю коэф.
    idx = argsort(data_fft_power[:, :, :half])  # Учитываем симметрию
    idx = idx[:, :, ::-1] # Нужно по убыванию
    idx = idx[:, :, :N]
    
    # Получили трехмерный массив в котором хранятся
    # номера наиболее значимых гармоник. Но у разных пикселей
    # значимые гармоники могут быть разными.
    # Отсортируем еще раз и найдем N самых важных
    u, counts = np.unique(idx, return_counts=True)  # Считаем элементы
    structure = [('counts', int), ('index', int)]   # Затем объединим их в структуру и отсортируем
    vals = np.array(zip(counts, u), dtype=structure)
    ordered = np.sort(vals, order='counts')[::-1]

    # Отрежем N самых частотных
    idx = ordered[:N]
    res = []
    # Соберем окончательный массив индексов
    for (count, u) in idx:
        res.append(u)
        if u != 0 and return_sym:    # учтем симметрию, если нужно
            res.append(size - u)
            
    return res
    

def filter_ndvi_data(data, N):
    """Возвращает копию данных к которым применены следующие
    действия:
    Преобразование Фурье.
    Зануление всех коэффициентов, кроме N значимых.
    Обратное преобразование Фурье.
    """
    
    data_fft = fft(data, axis=2)
    
    idx = get_max_coef_idx(data_fft, N)
        
    filtered = np.zeros(data_fft.shape, dtype=np.complex)
    filtered[:, :, idx] = data_fft[:, :, idx]
    
    ifiltered = ifft(filtered).real
    
    return ifiltered

Проверим работу функции на одном наугад взятом пикселе


In [6]:
N = 15  # Большое, чтобы были видны характерные детали

filtered = filter_ndvi_data(data, N)

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(data[10, 20, :], '-bo', label='Data')  
plt.plot(filtered[10, 20, :], '-ro', label='Filtered')
plt.legend()
plt.show()


Функция качества восстановления данных по $N$ коэффициентам

Можно взять массив data и прогнать его через функцию filter_ndvi_data, а потом сравнить полученный сигнал с исходным. Разность исходного и восстановленного сигналов может служить фукнцией качества. Однако очевидно, что:

  1. Если взять $N$ равным числу отсчетов, то погрешность восстановления будет минимальна, при этом восстановится не только полезный сигнал, но и шум.
  2. Нас интересует полезный сигнал, поэтому качество восстановления сигнала должно оцениваться не по непосредственной точности восстановления сигнала, а по предсказательной способности на данных, не предъявлявшихся алгоритму в ходе его настройки.

Поэтому подберем $N$ на основе процедуры перекрестной проверки. В обучающее множество положим отсчеты с четными индексами, в тестовое -- с нечетными. (Такой подход к разбиению обусловлен упрощением кода и вычислений -- для преобразования Фурье нужно, чтобы отсчеты были равноотстоящими, а изъятие данных случайным образом значительно затруднит вычисления, поэтому разбиение произведем систематически).

После разбиения на обучающем множестве прогоним функцию filter_ndvi_data и получим сглаженные кривые NDVI для каждого пикселя. Затем произведем линейную интерполяцию полученных кривых и проинтерполированные значения со значениями, лежащих в тестовом множестве. Величина погрешности на тестовом множестве и будет оценкой качества.


In [7]:
# Функция разбиения на тестовое и обучающее множество
def split_data(data):
    size = data.shape[-1]
    train = data[:, :, 0:size:2]
    test = data[:, :, 1:size:2]
    
    # Если число отсчетов в data четное,
    # то в test один остчет "торчит" наружу
    # (за пределы области изменения обучающего множества)
    # поэтому удалим последний отсчет в теством множестве
    if len(train[0, 0, :]) == len(test[0, 0, :]): 
        test = test[:, :, :-1]
    
    return (train.copy(), test.copy())

Покажем на примере одного пикселя, как будем считать ошибку фильтрации:


In [8]:
train, test = split_data(data)

N = 6
size = data.shape[-1]

filtered = filter_ndvi_data(train, N)

# Индексы отсчетов обучающего множества: 0, 2, ..., N
t_train = np.arange(0, size, 2)
# Индексы отсчетов тестового множества: 1, 3, ..., N-1
t_test = t_train[:-1].copy()
t_test += 1

# Проинтерполируем отфильтрованные результаты в точках, попавших в тествоем множество
recieved = np.interp(t_test, t_train, filtered[20, 20, :])

fig = plt.figure(figsize=(12,6), dpi=100)
plt.plot(t_train, filtered[20, 20, :], '-go', label='Filtered')
plt.plot(t_train, train[20, 20, :], '-bo', label='Train') 
plt.plot(t_test, test[20, 20, :], '-ro', label='Test')
plt.plot(t_test, recieved, '^', label='Interpolated filtered')
plt.legend()
plt.show()

# Считаем ошибку
from sklearn.metrics import mean_squared_error
err = mean_squared_error(test[20, 20, :], recieved)
print err


1420536.73304

Оформим приведенный код в виде функции:


In [9]:
def estimate_error(train, test, N):
    filtered = filter_ndvi_data(train, N)

    # Индексы отсчетов обучающего множества: 0, 2, ..., N
    t_train = np.arange(0, size, 2)
    # Индексы отсчетов тестового множества: 1, 3, ..., N-1
    t_test = t_train[:-1].copy()
    t_test += 1
    
    err = 0

    dim_x, dim_y, dim_ndvi = train.shape
    recieved = np.zeros(test.shape)
    
    # TODO: этот цикл очень неэффективный. Исправить на использование numpy
    for i in range(dim_x):
        for j in range(dim_y):
            recieved[i, j, :] = np.interp(t_test, t_train, filtered[i, j, :])
            err += mean_squared_error(test[i, j, :], recieved[i, j])
    return err

Находим "нужное" N. Код ниже закомментирован, поскольку выполняется довольно долго.


In [10]:
# size = data.shape[-1]
# train, test = split_data(data)

# errors = np.zeros(size/2 -1)
# errors[:] = inf

# for N in range(1, size/2):
#     cur_err = estimate_error(train, test, N)
#     print N, cur_err
#     errors[N-1] = cur_err

# fig = plt.figure(figsize=(12,6), dpi=100)
# plt.plot(range(2, size/2), errors[1:], '-o', label='Errors') # ошибка при N=1 слишком велика
# plt.legend()
# plt.show()

Из графика видно, что минимум ошибки достигается при N=4. При этом падение ошибки идет резко при N=1 и N=2, после N=2 (N=3, N=4) падение ошибки незначительное.

Как видно из фрагмента вывода ошибок, при классификации нужно будет использовать не менее двух коэффициентов. Оптимум получим при четырех, но он незначительно лучше, чем при двух. Поэтому мы можем с чистой совестью использовать 2 коэффициента. Тем не менее сохраним N=4 наиболее значимых коэффициентов в виде массивов (а вдруг пригодятся):


In [11]:
data_fft = fft(data, axis=2)

N = 4
idx = get_max_coef_idx(data_fft, N, return_sym=False)
print idx
idx.sort()  # После сортировки теряем информацию о значимости
print idx

# Массив самых значимых коэффициентов
coefs = data_fft[:, :, idx]


[3, 0, 9, 6]
[0, 3, 6, 9]

Сохраним полученные массивы как растры GRASS.

Коэффициент $A_0$ (константа) является действительным, поэтому обработаем его отдельно:


In [12]:
band = grass.garray.array()
band[...] = coefs[:, :, 0].real
band.write('Const', overwrite=True)


Out[12]:
0

Сохраним оставшиеся коэффициенты:


In [13]:
for i in range(1, 4):
    band = grass.garray.array()
    band[...] = coefs[:, :, i].real
    band.write('Sin' + str(idx[i])+'.real', overwrite=True)
    band[...] = coefs[:, :, i].imag
    band.write('Sin' + str(idx[i])+'.image', overwrite=True)

Разведочный анализ

Прочитаем растры с тренировочными данными. В наборе имеется три растра train1, train2 и train3, которые соотвествуют обучающим классам на трех уровнях иерархии. В таблице приведены значения, которыми закодированы классы:

№ класса в train1 название класса в train1 № класса в train2 название класса в train2 № класса в train3 название класса в train3 Сложный для распознавания? Коментарий Редкий?
1 Ледники постоянные 1 Ледники постоянные 1 Ледники постоянные
2 Вода открытая 2 Вода «синяя» 2 Вода «синяя»
2 Вода открытая 3 Вода «черная» 3 Вода «черная»
3 Лишенные растительности участки 4 Пески 4 Пески
3 Лишенные растительности участки 5 Застройка 5 Застройка
4 Пойменные луга 6 Пойменные луга 6 Пойменные луга
5 Болота 7 Олиготрофные 10 Грядово-мочажинные
5 Болота 7 Олиготрофные 11 Рямы
5 Болота 7 Олиготрофные 12 Плоскобугристые 1 Близки с классом лишайниковых тундр
5 Болота 7 Олиготрофные 13 Грядово-озерковые и ГТК
5 Болота 8 Мезотрофные 20 Вахтово-сфагновые 1 Смешиваются с ГМК (5-7-10) Да
5 Болота 8 Мезотрофные 21 Гипновые топи (лазиокарпа) 1 Плохо отделяются (смешиваются с несколькими классами)
5 Болота 8 Мезотрофные 22 Хасыреи 1 Плохо отделяются (смешиваются с несколькими классами)
5 Болота 9 Мезотрофные 31 Разнотравно-гипновые богаого грунтового питания Плохо отделяются (смешиваются с несколькими классами) Да
5 Болота 9 Эутрофные 30 Согры 1 Смешиваются с темнохвойными лесами
6 Леса 10 Лиственные 40 Лиственные
6 Леса 11 Смешанные 41 Смешанные
6 Леса 12 Хвойные (не беломошные) 42 Темнохвойные
6 Леса 12 Хвойные (не беломошные) 44 Сосново-кустарничково-моховые (редкостойные) 1 Смешиваются с рямами
6 Леса 13 Беломошные 43 Сосновые и лиственничные беломошные
6 Леса 14 Гари (возраст примерно 10 и более лет) 45 Гари
7 Тундры 15 Тундры горные 50 Тундры горные
7 Тундры 16 Тундры равнинные 51 Тундры равнинные
8 Пашня 17 Пашня 70 Пашня

Прочитаем обучающие растры и проверим, какие классы присутствуют в данных. (Класс номер 0 -- отсутствие информации).


In [14]:
train1 = grass.garray.array()
train1.read('train1')

train2 = grass.garray.array()
train2.read('train2')

train3 = grass.garray.array()
train3.read('train3')

print 'Уникальные классы в train1:', np.unique(train1)
print 'Уникальные классы в train2:', np.unique(train2)
print 'Уникальные классы в train3:', np.unique(train3)


Уникальные классы в train1: [ 0.  2.  3.  4.  5.  6.]
Уникальные классы в train2: [  0.   2.   3.   5.   6.   7.   8.   9.  10.  11.  12.  13.  14.]
Уникальные классы в train3: [  0.   2.   3.   5.   6.  10.  13.  20.  21.  31.  40.  41.  42.  43.  44.
  45.]

Индексы пикселей, для которых известны номера классов:


In [15]:
rows1, cols1 = np.nonzero(train1)
rows2, cols2 = np.nonzero(train2)
rows3, cols3 = np.nonzero(train1)

Первый уровень иерархии классов

Рассмотрим сначала классы на первом уровне иерархии. Возьмем коэффициенты разложения Фурье и сравним их для разных классов.


In [16]:
data_fft = fft(data, axis=2)

N = 4
idx = get_max_coef_idx(data_fft, N, return_sym=False)
print idx

coefs1 = data_fft[:, :, idx]


[3, 0, 9, 6]

Создадим фрейм данных.


In [17]:
labels = {2: 'Water', 3: 'Non-vegetation', 4: 'Grassland', 5: 'Swamp', 6: 'Forest'}
cats = pd.Series([labels[cat] for cat in train1[rows1, cols1]], dtype="category")
df1 = pd.DataFrame({'Class': train1[rows1, cols1], 
                       'Names': cats,
                       'Const': coefs1[rows1, cols1, 1].real, 
                       'Sin3.imag': coefs1[rows1, cols1, 0].imag,
                       'Sin3.real': coefs1[rows1, cols1, 0].real,
                       'Sin9.imag': coefs1[rows1, cols1, 2].imag,
                       'Sin9.real': coefs1[rows1, cols1, 2].real,
                       'Sin6.imag': coefs1[rows1, cols1, 3].imag,
                       'Sin6.real': coefs1[rows1, cols1, 3].real,
                       })
df1.head()


Out[17]:
Class Const Names Sin3.imag Sin3.real Sin6.imag Sin6.real Sin9.imag Sin9.real
0 6 318422 Forest -4086.604998 -137111.596914 -27649.417582 -21909.947991 -12327.426203 -25758.840913
1 6 326910 Forest -4088.332030 -136214.872925 -31563.641739 -29582.285714 -17683.045981 -29618.875323
2 6 318321 Forest 6191.732207 -131802.568804 -26059.922935 -17823.119377 -20069.698721 -29305.804071
3 6 330433 Forest -8061.295787 -136894.478380 -28379.360149 -27683.463739 -14565.158124 -32054.940337
4 6 325978 Forest -1957.844120 -133842.910826 -26195.230024 -20651.537386 -19581.053314 -29048.810456

Распределение по классам


In [18]:
df1.hist(column='Class', bins=50)


Out[18]:
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x7fe73f72f850>]], dtype=object)

Видно, что классы представлены неравномерно. В частности для третьего класса (участков, лишенных растительности) данных очень мало. Большее всего примеров на лесные территории.

Построим "ящики с усами".


In [19]:
df1.boxplot(column='Const', by='Names')


Out[19]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe725ae09d0>

Из диаграммы, построенной по одному коэффициенту (константе, которая может интерпретироватсья как среднее значение NDVI), видно:

  1. Один класс (вода) очень хорошо отделяется от других: вода никак не пересекается с остальными классами. Для того, чтобы его выделить воду достаточно использовать свободный член, остальные коэффициенты даже не потребуются.
  2. Свободный член почти не изменяется для территорий без растительности. Но это может объясняться не только особенностями класса, но и тем, что примеров классов, лишенных растительности очень мало.
  3. Болота и пойменные луга должны довольно просто отделяться один от другого по свободному члену (среднему NDVI).
  4. Сложнее всего будет отделить болота и луга от лесных территорий. При этом часть лесов, характеризующихся высоким значением свободного члена (все, что лежит за пределами первого квартиля) может быть с большой достоверностью отделены от болот (и тем более от лугов). Смешиваться будут лесные территории, у которых свободный член лежит в первом квартиле, с классами болот/лугов.

Посмотрим, что происходит при использовании синусоид в качестве объясняющих переменных.


In [20]:
df1.boxplot(column='Sin3.real', by='Names')


Out[20]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe7259b9210>

In [21]:
df1.boxplot(column='Sin3.imag', by='Names')


Out[21]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe725763d90>

Сами по себе коэффициенты мало что привнесли к тому, что мы уже знаем. Посмотрим, что дадут амплитуды синусоид. Для этого добавим в таблицу еще одну колонку -- апмлитуд.


In [22]:
df1['Amp3'] = sqrt(df1['Sin3.imag']**2 + df1['Sin3.real']**2)
df1['Amp9'] = sqrt(df1['Sin9.imag']**2 + df1['Sin9.real']**2)
df1['Amp6'] = sqrt(df1['Sin6.imag']**2 + df1['Sin6.real']**2)
df1.head()


Out[22]:
Class Const Names Sin3.imag Sin3.real Sin6.imag Sin6.real Sin9.imag Sin9.real Amp3 Amp9 Amp6
0 6 318422 Forest -4086.604998 -137111.596914 -27649.417582 -21909.947991 -12327.426203 -25758.840913 137172.483934 28556.668607 35277.983412
1 6 326910 Forest -4088.332030 -136214.872925 -31563.641739 -29582.285714 -17683.045981 -29618.875323 136276.212395 34495.911215 43259.393291
2 6 318321 Forest 6191.732207 -131802.568804 -26059.922935 -17823.119377 -20069.698721 -29305.804071 131947.924164 35519.332187 31571.873047
3 6 330433 Forest -8061.295787 -136894.478380 -28379.360149 -27683.463739 -14565.158124 -32054.940337 137131.625457 35208.848762 39645.456827
4 6 325978 Forest -1957.844120 -133842.910826 -26195.230024 -20651.537386 -19581.053314 -29048.810456 133857.229659 35032.142923 33356.799493

In [23]:
df1.boxplot(column='Amp3', by='Names')


Out[23]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe725632810>

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

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


In [24]:
water = df1[df1['Class'] == 2]
forest = df1[df1['Class'] == 6]
non_veget = df1[df1['Class'] == 3]
grass_land = df1[df1['Class'] == 4]
swamp = df1[df1['Class'] == 5]

fig = plt.figure(figsize=(8, 8), dpi=100)
plt.plot(water['Amp3'], water['Const'], 'r+', label='Water')
plt.plot(forest['Amp3'], forest['Const'], 'g+', label='Forest')
plt.plot(non_veget['Amp3'], non_veget['Const'], 'b*', label='Non vegetation')
plt.plot(grass_land['Amp3'], grass_land['Const'], 'r*', label='Grasslands')
plt.plot(swamp['Amp3'], swamp['Const'], 'b+', label='Swamp')
plt.legend()
plt.ylabel('Const')
plt.xlabel('Amp3')
plt.show()


Дополнительно к тому, что мы уже узнали ранее, из диаграммы видим:

  1. Леса -- очень неоднородная и разнообразная по свойствам группа, она распадается на три-четыре-пять кластеров в зависимости от уровня детализации. При этом леса на диаграмме в целом относительно хорошо отделяются от остальных классов (чего не было видно при изучении отдельных коэффициентов). Некоторая путаница может возникнуть при отделении части болот от лесов.
  2. Луга неплохо отделяются от остальных классов: у лугов относительно низкое среднее значение NDVI (свободный член) и относительно высокие амплитуды синусоид.
  3. Болота тоже неоднородная группа, но подгруппы внутри болот менее выражены, чем подгруппы внутри лесов. Болота относительно легко отделяются от остальных классов, но при этом есть примеры, когда болота "наползают" на участки диаграммы, занятые лесом.
  4. Облако точек, соответствующее воде имеет странную форму -- с изломом посередине.

Если посмотреть диаграммы по другим синусам, то каких-то новых результатов увидеть не удается.

Второй уровень иерархии


In [25]:
labels = {1: 'Glaciers', 
          2: 'Blue water', 
          3: 'Black water', 
          4: 'Sands', 
          5: 'Build', 
          6: 'Grass', 
          7: 'Oligotr.', 
          8: 'Mesotr.', 
          9: 'Eutrofnye', 
          10: 'Hardwood', 
          11: 'Mixed f.', 
          12: 'Con.(not wh-moss)',
          13: 'Wh-moss Conifer',
          14: 'Burned', 
          15: 'Mount.tundra', 
          16: 'Pl.tundra', 
          17: 'Arable land'}

train2 = grass.garray.array()
train2.read('train2')
cats = pd.Series([labels[cat] for cat in train2[rows1, cols1]], dtype="category")
df2 = pd.DataFrame({'Class': train2[rows1, cols1], 
                       'Names': cats,
                       'Const': coefs1[rows1, cols1, 1].real, 
                       'Sin3.imag': coefs1[rows1, cols1, 0].imag,
                       'Sin3.real': coefs1[rows1, cols1, 0].real,
                       'Sin9.imag': coefs1[rows1, cols1, 2].imag,
                       'Sin9.real': coefs1[rows1, cols1, 2].real,
                       'Sin6.imag': coefs1[rows1, cols1, 3].imag,
                       'Sin6.real': coefs1[rows1, cols1, 3].real,
                       })
df2['Amp3'] = sqrt(df2['Sin3.imag']**2 + df2['Sin3.real']**2)
df2['Amp9'] = sqrt(df2['Sin9.imag']**2 + df2['Sin9.real']**2)
df2['Amp6'] = sqrt(df2['Sin6.imag']**2 + df2['Sin6.real']**2)
df2.head()


Out[25]:
Class Const Names Sin3.imag Sin3.real Sin6.imag Sin6.real Sin9.imag Sin9.real Amp3 Amp9 Amp6
0 11 318422 Mixed f. -4086.604998 -137111.596914 -27649.417582 -21909.947991 -12327.426203 -25758.840913 137172.483934 28556.668607 35277.983412
1 11 326910 Mixed f. -4088.332030 -136214.872925 -31563.641739 -29582.285714 -17683.045981 -29618.875323 136276.212395 34495.911215 43259.393291
2 11 318321 Mixed f. 6191.732207 -131802.568804 -26059.922935 -17823.119377 -20069.698721 -29305.804071 131947.924164 35519.332187 31571.873047
3 11 330433 Mixed f. -8061.295787 -136894.478380 -28379.360149 -27683.463739 -14565.158124 -32054.940337 137131.625457 35208.848762 39645.456827
4 11 325978 Mixed f. -1957.844120 -133842.910826 -26195.230024 -20651.537386 -19581.053314 -29048.810456 133857.229659 35032.142923 33356.799493

In [26]:
df2.hist(column='Class', bins=50)


Out[26]:
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x7fe7254b2750>]], dtype=object)

Как и ранее видим, что количество примеров в разных классах сильно варьируется.


In [27]:
df2.boxplot(column='Const', by='Names', figsize=(15,10))


Out[27]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe725a96250>

In [28]:
df2.boxplot(column='Amp3', by='Names', figsize=(15,10))


Out[28]:
<matplotlib.axes._subplots.AxesSubplot at 0x7fe724e3d910>

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


In [29]:
blue_w = df2[df2['Class'] == 2]
black_w = df2[df2['Class'] == 3]
build = df2[df2['Class'] == 5]
grassland = df2[df2['Class'] == 6]
ologotr = df2[df2['Class'] == 7]
mesotr = df2[df2['Class'] == 8]
eutr = df2[df2['Class'] == 9]
hardw = df2[df2['Class'] == 10]
mixed = df2[df2['Class'] == 11]
non_wh_mos_con = df2[df2['Class'] == 12]
wh_moss_c = df2[df2['Class'] == 13]
burned = df2[df2['Class'] == 14]


fig = plt.figure(figsize=(8, 8), dpi=100)

plt.plot(blue_w['Amp3'], blue_w['Const'], 'b+', label='Blue Water')
plt.plot(black_w['Amp3'], black_w['Const'], 'k+', label='Black Water')
plt.plot(build['Amp3'], build['Const'], 'y^', label='Buildins')
plt.plot(grassland['Amp3'], grassland['Const'], 'g*', label='Grass')

plt.plot(ologotr['Amp3'], ologotr['Const'], 'rv', label='Oligotrophic')
plt.plot(mesotr['Amp3'], mesotr['Const'], 'b*', label='Mesotrophic')
plt.plot(eutr['Amp3'], eutr['Const'], 'c^', label='Eutrofic')

plt.plot(hardw['Amp3'], hardw['Const'], 'ro', label='Hardwood')
plt.plot(mixed['Amp3'], mixed['Const'], 'g+', label='Mixed forest')
plt.plot(non_wh_mos_con['Amp3'], non_wh_mos_con['Const'], 'k^', label='Conifer (not white moss)')
plt.plot(wh_moss_c['Amp3'], wh_moss_c['Const'], 'mo', label='Conifer (white moss)')
plt.plot(burned['Amp3'], burned['Const'], 'y+', label='Burned')

plt.ylabel('Const')
plt.xlabel('Amp3')
plt.legend(loc=2)
plt.show()


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


In [30]:
fig = plt.figure(figsize=(8, 8), dpi=100)

plt.plot(ologotr['Amp3'], ologotr['Const'], 'rv', label='Oligotrophic')
plt.plot(mesotr['Amp3'], mesotr['Const'], 'b*', label='Mesotrophic')
plt.plot(eutr['Amp3'], eutr['Const'], 'c^', label='Eutrofic')

plt.plot(hardw['Amp3'], hardw['Const'], 'ro', label='Hardwood')
plt.plot(mixed['Amp3'], mixed['Const'], 'g+', label='Mixed forest')
plt.plot(burned['Amp3'], burned['Const'], 'y+', label='Burned')

plt.legend(loc=0)
plt.ylabel('Const')
plt.xlabel('Amp3')
plt.show()


  1. Нашлось объяснение для странной формы облака точек у водных объектов: это два разных вида воды. При этом видно, что черная вода и синяя вода -- не два абсолютно разных объекта, а один, плавно перетекающий в другой: облака точек соприкасаются для объектов с низкими амплитудами NDVI. С ростом амплитуды NDVI у синей воды падает среднее значение NDVI, а у черной возрастает. Таким образом, черная вода отличается от синей тем, что в черной более выражена сезонная компонента, по видимости, в ней есть нечто, что "цветет" летом или же в черной воде "просвечивают" водоросли или еще что-то в этом роде. На самом деле "Синяя" вода - мутная и меньшие колебания NDVI объясняются тем, что обильная муть нивелирует сезонную динамику водорослей.
  2. Городская застройка образует компактную группу. При этом эта группа имеет явно большие амплитуды и средние значения NDVI, чем вода. Т.е. в полигоны застройки попадаются не только крыши домов, но и относительно зеленые зоны внутри застроки.
  3. Луга имеют относительно высокие значения амплитуд NDVI, но низкое среднее значение.
  4. Болота. Выделяются в отдельные смещивающиеся между собой сгущения точек на диаграмме, отделимые от остальных классов (кроме части примеров по мезотрофным болотам, которые "заползают" в область леса. Отличия болот между собой:
    1. Олиготрофные болота. По сравнению с другими болотами имеют меньшие амплитуды NDVI.
    2. Эутрофные болота. Распадаются на две части: первая (большая по числу примеров часть) имеет самые высокие значения амплитуд NDVI среди болот; вторая группа примеров имеет не высокие и не низкие значения амплитуд, но очень высокие средние значения NDVI по сравнению с другими типами болот. Вторая группа эутрофных болот сильно смешивается со сгоревшими лесными участками по амплитудам и средним значениям NDVI.
    3. Мезотрофные болота. Примеры также разбиваются на два кластера: первый кластер представляет собой нечто среднее между олиготрофными и эутрофными болотами; второй кластер по значению амлитуд сравним с "основным кластером" эутрофных болот, но при этом средние значения NDVI у мезотрофных болот будет выше, чем у эутрофных.
  5. Леса.
    1. Лиственные леса разбиваются на два четко обособленных друг от друга кластера, отличающихся амплитудами NDVI. Между этими кластерами вклинивается облако точек, соответствующее смешанному лесу. Это поведение несколько странно -- ожидалось, что смешанный лес окажется между хвойным и лиственным, а не между двумя видами лиственного.
    2. Хвойный лес четко отделяется от остальных видов леса за счет низких значений амплитуд NDVI (правда, незначительная часть примеров беломошного леса "вытянулась" в сторону смешанных лесов). При этом беломошный и небеломошные леса также хорошо различимы между собой. (Амплитуды беломошного и небеломошного леса одинаковы, но они значительно различаются по среднему NDVI).
    3. Сгоревшие лесные участки лежат в области, близкой одному из кластеров эутрофных болот, и частично смешиваются с ними.

Третий уровень иерархии

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

В описании обучающих данных два класса под номером 43. Скорее всего ошибка и класс "Сосновые и лиственничные беломошные леса" должен идти под номером 44.


In [31]:
labels = {
    10: 'Ridge-hollow', 
    11: 'Ryam', 
    12: 'Plain-hillock', 
    13: 'Ridge-lake', 
    20: 'Shift-sphagnum', 
    21: 'Sedge marshes', 
    22: 'Hasyrei', 
    31: 'Herb-rich', 
    30: 'Sogra', 
    40: 'Hardwood', 
    41: 'Mixed', 
    42: 'Dark coniferous', 
    44: 'Pine-shrub-moss', 
    43: 'Pine and larch wh-moss',
    45: 'Burned'
}

train3 = grass.garray.array()
train3.read('train3')
cats = pd.Series([labels[cat] if cat in labels else '-' for cat in train3[rows1, cols1]], dtype="category")
df3 = pd.DataFrame({'Class': train3[rows1, cols1], 
                       'Names': cats,
                       'Const': coefs1[rows1, cols1, 1].real, 
                       'Sin3.imag': coefs1[rows1, cols1, 0].imag,
                       'Sin3.real': coefs1[rows1, cols1, 0].real,
                       'Sin9.imag': coefs1[rows1, cols1, 2].imag,
                       'Sin9.real': coefs1[rows1, cols1, 2].real,
                       'Sin6.imag': coefs1[rows1, cols1, 3].imag,
                       'Sin6.real': coefs1[rows1, cols1, 3].real,
                       })

df3 = df3[df3['Names'] != '-']

df3['Amp3'] = sqrt(df3['Sin3.imag']**2 + df3['Sin3.real']**2)
df3['Amp9'] = sqrt(df3['Sin9.imag']**2 + df3['Sin9.real']**2)
df3['Amp6'] = sqrt(df3['Sin6.imag']**2 + df3['Sin6.real']**2)
df3.head()


Out[31]:
Class Const Names Sin3.imag Sin3.real Sin6.imag Sin6.real Sin9.imag Sin9.real Amp3 Amp9 Amp6
0 41 318422 Mixed -4086.604998 -137111.596914 -27649.417582 -21909.947991 -12327.426203 -25758.840913 137172.483934 28556.668607 35277.983412
1 41 326910 Mixed -4088.332030 -136214.872925 -31563.641739 -29582.285714 -17683.045981 -29618.875323 136276.212395 34495.911215 43259.393291
2 41 318321 Mixed 6191.732207 -131802.568804 -26059.922935 -17823.119377 -20069.698721 -29305.804071 131947.924164 35519.332187 31571.873047
3 41 330433 Mixed -8061.295787 -136894.478380 -28379.360149 -27683.463739 -14565.158124 -32054.940337 137131.625457 35208.848762 39645.456827
4 41 325978 Mixed -1957.844120 -133842.910826 -26195.230024 -20651.537386 -19581.053314 -29048.810456 133857.229659 35032.142923 33356.799493

In [32]:
df3.hist(column='Class', bins=50)


Out[32]:
array([[<matplotlib.axes._subplots.AxesSubplot object at 0x7fe7250dcf90>]], dtype=object)

In [33]:
labels = {
    10: 'Ridge-hollow', 
    11: 'Ryam', 
    12: 'Plain-hillock', 
    13: 'Ridge-lake', 
    20: 'Shift-sphagnum', 
    21: 'Sedge marshes', 
    22: 'Hasyrei', 
    31: 'Herb-rich', 
    30: 'Sogra', 
    40: 'Hardwood', 
    41: 'Mixed', 
    42: 'Dark coniferous', 
    44: 'Pine-shrub-moss', 
    43: 'Pine and larch wh-moss', 
    45: 'Burned'
}


r_h = df3[df3['Class'] == 10]
ryam = df3[df3['Class'] == 11]
p_h = df3[df3['Class'] == 12]
r_l = df3[df3['Class'] == 13]
s_s = df3[df3['Class'] == 20]
s_m = df3[df3['Class'] == 21]
haserey = df3[df3['Class'] == 22]
sogra = df3[df3['Class'] == 30]
h_r = df3[df3['Class'] == 31]
hardw = df3[df3['Class'] == 40]
mixed = df3[df3['Class'] == 41]
dark = df3[df3['Class'] == 42]
p_s_m = df3[df3['Class'] == 43]
p_l_m = df3[df3['Class'] == 44]   # Проверить номер класса
burned = df3[df3['Class'] == 45]


fig = plt.figure(figsize=(13, 13), dpi=100)

plt.plot(r_h['Amp3'], r_h['Const'], 'b+', label='Ridge-hollow (10)')
plt.plot(r_l['Amp3'], r_l['Const'], 'k+', label='Ridge-lake (13)')
plt.plot(s_s['Amp3'], s_s['Const'], 'g+', label='Shift-sphagnum (20)')
plt.plot(s_m['Amp3'], s_m['Const'], 'c+', label='Sedge marshes (21)')
plt.plot(h_r['Amp3'], h_r['Const'], 'mv', label='Herb-rich (31)')
plt.plot(hardw['Amp3'], hardw['Const'], 'y+', label='Hardwood (40)')
plt.plot(mixed['Amp3'], mixed['Const'], 'r^', label='Mixed forest (41)')
plt.plot(dark['Amp3'], dark['Const'], 'b^', label='Dark coniferous (42)')
plt.plot(p_s_m['Amp3'], p_s_m['Const'], 'g^', label='Pine-shrub-moss (43)')
plt.plot(p_l_m['Amp3'], p_l_m['Const'], 'ko', label='?Pine and larch wh-moss? (44)')
plt.plot(burned['Amp3'], burned['Const'], 'c^', label='Burned (45)')

# Нет в обучающией выборке:
# plt.plot(ryam['Amp3'], ryam['Const'], 'go', label='Ryam (11)')
# plt.plot(p_h['Amp3'], p_h['Const'], 'yo', label='Plain-hillock (12)')
# plt.plot(haserey['Amp3'], haserey['Const'], 'yo', label='Haserey (22)')
# plt.plot(sogra['Amp3'], sogra['Const'], 'cv', label='Sogra (30)')

plt.ylabel('Const')
plt.xlabel('Amp3')
plt.legend(loc=1)
plt.show()


Рассмотрим следующие классы, которые помечены как сложные для распознавания. При этом были выброшены из анализа классы 12, 22, 30, поскольку они не присутствуют в тренировочных данных

№ класса в train1 название класса в train1 № класса в train2 название класса в train2 № класса в train3 название класса в train3 Коментарий
5 Болота 8 Мезотрофные 20 Вахтово-сфагновые Смешиваются с ГМК (5-7-10)
5 Болота 8 Мезотрофные 21 Гипновые топи (лазиокарпа) Плохо отделяются (смешиваются с несколькими классами)
5 Болота 9 Мезотрофные 31 Разнотравно-гипновые богатого грунтового питания Плохо отделяются (смешиваются с несколькими классами)
6 Леса 12 Хвойные (не беломошные) 43 Сосново-кустарничково-моховые (редкостойные) Смешиваются с рямами
  1. Класс "Вахтово-сфагновые болота" характеризуется высокими значениями амплитуд NDVI и относительно низким средним NDVI. Находится в области, частично перекрывающейся с классом "Разнотравно-гипновые богаого грунтового питания болота". Тем не менее класс вахтово-сфагновых болот отличается от разнотравных тем, что у него более высокий средний уровень NDVI. Вполне возможно, что этот класс можно отделить с достаточной достоверностью от других.
  2. Класс "Гипновые топи (лазиокарпа)" находится в центре облака точек, образуемого классами "Грядово-мочажинных болот", "Грядово-озерковых болот" и "Сосновых и лиственничных беломошных лесов". Тем не менее этот класс образует компактную группу внутри указанных классов.
  3. Класс "Разнотравно-гипновые богаого грунтового питания болота" образует два сгущения. Одно из них наибольшее по числу точек, компактное, достаточно хорошо отделимое от остальных классов граничит с классом вахтово-сфагновых болот. Второе скопление точек является очень разреженным и лежит в области, принадлежащей нескольким классам: гари, сосновые и лиственничные беломошные леса. Эта часть примеров будет скорее всего неверно классифицирована.
  4. Класс "Сосново-кустарничково-моховые (редкостойные) леса" смешиваются с рямами. Однако в обучающей выборке нет рямов, поэтому оценить качество разделения класов не удастся.
  5. Классы лиственных лесов и смешанных лесов в документации не помеченны как смешивающиеся между собой. Тем не менее на диаграмме видно, что они частично перекрываются. Вполне возможно это объясняется естесственными причинами -- классы могут смешиваться при большой доле лиственных лесов в классе "Смешанный лес".

Еще немного деталей


In [34]:
fig = plt.figure(figsize=(8, 8), dpi=100)

plt.plot(blue_w['Amp3'], blue_w['Const'], 'b+', label='Blue Water')
plt.plot(black_w['Amp3'], black_w['Const'], 'k+', label='Black Water')
plt.plot(build['Amp3'], build['Const'], 'y^', label='Buildins')
plt.plot(grassland['Amp3'], grassland['Const'], 'g*', label='Grass')

plt.plot(ologotr['Amp3'], ologotr['Const'], 'rv', label='Oligotrophic')
plt.plot(mesotr['Amp3'], mesotr['Const'], 'b*', label='Mesotrophic')
plt.plot(eutr['Amp3'], eutr['Const'], 'c^', label='Eutrofic')

plt.plot(hardw['Amp3'], hardw['Const'], 'ro', label='Hardwood')
plt.plot(mixed['Amp3'], mixed['Const'], 'g+', label='Mixed forest')
plt.plot(non_wh_mos_con['Amp3'], non_wh_mos_con['Const'], 'k^', label='Conifer (not white moss)')
plt.plot(wh_moss_c['Amp3'], wh_moss_c['Const'], 'mo', label='Conifer (white moss)')
plt.plot(burned['Amp3'], burned['Const'], 'y+', label='Burned')

plt.plot([0, 180000], [-35000, 235000], 'k-')

plt.ylabel('Const')
plt.xlabel('Amp3')
plt.legend(loc=2)
plt.show()


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

Эта линия интерпертируется следующим образом:

  • временной ряд NDVI в грубом приближении описывается синусоидальной волной вида $A_0 + A_3 \sin(3 t)$ (где для разных пикселей разных объектов будут ипользованы различные $A_0$ и $A_3$);
  • константа (свободный член) $A_0$ дает средний уровень NDVI за исследуемый период;
  • амплитуда синуса $A_3$ описывает степень вариации NDVI;
  • если зафиксировать какой-либо средний уровень NDVI ($A_0$) и начать увеличивать амплитуду, то рано или позно наступит такой момент, когда синусоида в нижней своей части выйдет за разумные пределы (например, уйдет в отрицательную область для растительных классов);
  • в то же время для описания некоторых классов необходимо использовать большие амплитуды, как следствие это тянет за собой автоматическое повышение константы, которая прибавляется к синусу;
  • в результате многие классы (понятно, не все) вытянуты вдоль этой умозрительной прямой, например: олиготрофные болота, мезотрофные болота, беломошники, пойменные луга, черная вода.

Найдем приблизительной уравнение прямой и вычтем его из данных. Это позволит "нормализировать" уровень NDVI для классов.

Уравнение зависимости константы от амлитуды:


In [35]:
def amp(c):
    x1, x2 = 0, 180000
    y1, y2 = -35000, 235000
    return (c-x1)*(y2-y1)/(x2-x1) + y1

Собственно сам график.


In [36]:
df3['Const_a'] = df3['Const'] - amp(df3['Amp3'])

grassland = df3[df3['Class'] == 6]
r_h = df3[df3['Class'] == 10]
ryam = df3[df3['Class'] == 11]
p_h = df3[df3['Class'] == 12]
r_l = df3[df3['Class'] == 13]
s_s = df3[df3['Class'] == 20]
s_m = df3[df3['Class'] == 21]
haserey = df3[df3['Class'] == 22]
sogra = df3[df3['Class'] == 30]
h_r = df3[df3['Class'] == 31]
hardw = df3[df3['Class'] == 40]
mixed = df3[df3['Class'] == 41]
dark = df3[df3['Class'] == 42]
p_s_m = df3[df3['Class'] == 44]
p_l_m = df3[df3['Class'] == 43]   
burned = df3[df3['Class'] == 45]

fig = plt.figure(figsize=(10, 10), dpi=100)

plt.plot(r_h['Amp3'], r_h['Const_a'], 'b+', label='Ridge-hollow (10)')
plt.plot(r_l['Amp3'], r_l['Const_a'], 'k+', label='Ridge-lake (13)')
plt.plot(s_s['Amp3'], s_s['Const_a'], 'g+', label='Shift-sphagnum (20)')
plt.plot(s_m['Amp3'], s_m['Const_a'], 'c+', label='Sedge marshes (21)')
plt.plot(h_r['Amp3'], h_r['Const_a'], 'mv', label='Herb-rich (31)')
plt.plot(hardw['Amp3'], hardw['Const_a'], 'y+', label='Hardwood (40)')
plt.plot(mixed['Amp3'], mixed['Const_a'], 'r^', label='Mixed forest (41)')
plt.plot(dark['Amp3'], dark['Const_a'], 'b^', label='Dark coniferous (42)')
plt.plot(p_s_m['Amp3'], p_s_m['Const_a'], 'g^', label='Pine-shrub-moss (43)')
plt.plot(p_l_m['Amp3'], p_l_m['Const_a'], 'ko', label='?Pine and larch wh-moss? (44)')
plt.plot(burned['Amp3'], burned['Const_a'], 'c^', label='Burned (45)')
plt.plot(grassland['Amp3'], grassland['Const_a'], 'g*', label='Grass')

# Нет в обучающией выборке:
# plt.plot(ryam['Amp3'], ryam['Const'], 'go', label='Ryam (11)')
# plt.plot(p_h['Amp3'], p_h['Const'], 'yo', label='Plain-hillock (12)')
# plt.plot(haserey['Amp3'], haserey['Const'], 'yo', label='Haserey (22)')
# plt.plot(sogra['Amp3'], sogra['Const'], 'cv', label='Sogra (30)')

plt.ylabel('Const (adjst)')
plt.xlabel('Amp3')
plt.legend(loc=1)
plt.show()


Такая нормализованная диаграмма позволяет увидеть картину несколько более детально, в первую очередь она позволяет сравнивать уровни минимальные NDVI разных классов (видимо, уровни NDVI в зимний период?). После нормализации горизонтальные линии соотвествуют примерно одинаковым минимальным значениям NDVI, так, на нижнем уровне диаграммы расположилось горизонтальное облако точек пойменных лугов оно лежит ниже всех остальных классов, вероятно потому, что в зимний период луга полностью покрыты снегом. Часть классов приобрела четко выраженную горизонтальную направленность, например, вахтово-сфагновые болота, гипновые топи, грядово-озерковые болота. Беломошные леса также вытянулись горизонтально.

Тот факт, что, к примеру, гипновые топи лежат на диаграмме выше, чем пойменные луга позволяет предположить, что они покрываются снегом не полностью, и на них остается что-то, повышает уровень NDVI (ветки деревьев/кустов? хвойные?). То, что основной кластер грядово-мочажинных болот лежит на диаграмме еще выше, также должно иметь объяснение (еще больше веток?).


In [36]: