Урок

TensorFlow-GNN: Ръководство от край до край за графични невронни мрежи

Как да правите прогнози за графики, възли и ръбове, като използвате вашите собствени набори от данни Pandas/NetworkX

Специални благодарности на Алваро Санчес Гонзалес от DeepMind и Bryan Perozzi и Sami Abu-el-haija от Google, които ми помогнаха с този урок

Актуализиран на 22.04.2023 г. за дребни поправки и добавяне на подхода Graph Nets

Графичните данни са навсякъде. Графичните изследвания са в начален стадий и инструментите за моделиране на графични данни едва започват да се появяват. Това го прави идеалният момент да се включите, ако сте специалист по данни, който иска да се отличи. За съжаление, може да бъде трудно да сте на върха поради липса на уроци и поддръжка. Това ръководство се надява значително да намали тази болезнена точка.

Защо TensorFlow-GNN?

TF-GNN беше пуснат наскоро от Google за графични невронни мрежи, използващи TensorFlow. Въпреки че има други библиотеки на GNN, гъвкавостта на моделиране на TF-GNN, производителността на широкомащабни графики благодарение на разпределеното обучение и подкрепата на Google означава, че вероятно ще се появи като индустриален стандарт. Това ръководство предполага, че вече разбирате предимствата на тази библиотека, но моля, вижте този документ за повече информация и сравнения на ефективността. Също така вижте документацията за TF-GNN. Ако изобщо не сте запознати с GNN, вижте това ръководство за концептуално разбиране.

Какъв е недостатъкът?

С тази библиотека, която в момента е в алфа етап, кодът е много точен по отношение на структурите, входните форми и форматите, необходими за успешно моделиране. Това прави много трудно навигацията без водач. За съжаление, няма много информация за използването на TF-GNN. Ръководствата, които могнах да намеря, се фокусират върху същия случай на използване на прогнозиране на ниво контекст, като се използва предварително изграден набор от данни TensorFlow. Към момента на писане на това няма нито една стъпка за:

  • Правене на прогнози за ръбове или възли
  • Започнете с вашите собствени набори от данни Pandas или NetworkX
  • Създаване на набори от данни за задържане
  • Настройка на модела
  • Отстраняване на грешки, които може да срещнете

След добър месец препрочитане на документация, кодиране на принципа проба-грешка и малко директна помощ от разработчиците на TensorFlow в Google/DeepMind, реших да съставя това ръководство.

„Много [часове] умряха, за да ни донесат тази информация.“

Какво ще обхване това ръководство:

Първо, ще започнем много просто, за да свалим градивните елементи. След това ще преминем към по-напреднал пример — прогнози за колежански футболни конференции. Ето очертанията на това, което ще бъде обхванато:

  • TF-GNN елементи
    - Градивни елементи
    - Графичен тензор от Pandas
  • Настройка на данни
    - Графичен тензор от NetworkX
    - Инженеринг на функции
    - Създаване на тестови разделяния
    - Създаване на графика TensorFlow набор от данни
  • Изграждане на модела
    - Модел на възел
    - Модел на ръба
    - Контекстен модел
  • Грешки при отстраняване на неизправности
  • Настройка на параметри

TF-GNN елементи

Графиката се състои от възли и ръбове. Ето пример за проста графика, показваща хора (възли), които наскоро са имали контакт помежду си (ръбове):

Същата тази графика може също да бъде представена като таблици с възли и ръбове. Можем също да добавим функции към тези възли и ръбове. Например, можем да добавим „възраст“ като характеристика на възел и индикатор „е приятел“ като характеристика на ръба.

Когато добавяме ръбове към TF-GNN, трябва да индексираме по номер, а не по име. Можем да направим това така:

node_df = node_df.reset_index()

merge_df = node_df.reset_index().set_index('Name').rename(
    columns={'index':'Name1_idx'})
edge_df = pd.merge(edge_df,merge_df['Name1_idx'],
                   how='left',left_on='Name1',right_index=True)

merge_df = merge_df.rename(columns={'Name1_idx':'Name2_idx'})
edge_df = pd.merge(edge_df,merge_df['Name2_idx'],
                   how='left',left_on='Name2',right_index=True)

И накрая, може да имаме контекстна стойност за графиката. Например, може би тази група приятели е отбелязала среден резултат от 84% на определен тест. Това няма да означава много за този пример с една графика. Ако имахме други графики на приятели, може би бихме могли да предвидим резултати за нови групи приятели въз основа на научената групова динамика.

Графичен тензор от панди

С тези елементи вече можем да изградим основата за нашата GNN: графичен тензор.

import tensorflow_gnn as tfgnn

graph_tensor = tfgnn.GraphTensor.from_pieces(
    node_sets = {
        "People": tfgnn.NodeSet.from_fields(
            sizes = [len(node_df)],
            features ={
                'Age': np.array(node_df['Age'],
                                dtype='int32').reshape(len(node_df),1)})},
    edge_sets ={
        "Contact": tfgnn.EdgeSet.from_fields(
            sizes = [len(edge_df)],
            features = {
                'Is-friend': np.array(edge_df['Is-friend'],
                                      dtype='int32').reshape(len(edge_df),1)},
            adjacency = tfgnn.Adjacency.from_indices(
                source = ("People", np.array(edge_df['Name1_idx'], dtype='int32')),
                target = ("People", np.array(edge_df['Name2_idx'], dtype='int32'))))
  })

Забележете как функциите, които създадохме, се вписват във възлите и ръбовете. Структурата с вдлъбнатини улеснява добавянето на допълнителни възли, ръбове и функции. Например, можем лесно да добавим възли и ръбове за филмите, които всеки приятел е гледал, и този път да включим стойност на контекста на графиката.

graph_tensor = tfgnn.GraphTensor.from_pieces(
    context_spec = tfgnn.ContextSpec.from_field_specs(
        features_spec ={
            "score": [[0.84]]
        }),
    node_sets = {
        "People": tfgnn.NodeSet.from_fields(
            sizes = [len(node_df)],
            features ={
                'Age': np.array(node_df['Age'],
                                dtype='int32').reshape(len(node_df),1)}),
        "Movies": tfgnn.NodeSet.from_fields(
            sizes = [len(movie_df)],
            features ={
                'Name': np.array(movie_df['Name'],
                                 dtype='string').reshape(len(movie_df),1),
                'Length': np.array(movie_df['Length'],
                                   dtype='float32').reshape(len(movie_df),1)})},
    edge_sets ={
        "Contact": tfgnn.EdgeSet.from_fields(
            sizes = [len(edge_df)],
            features = {
                'Is-friend': np.array(edge_df['Is-friend'],
                                      dtype='int32').reshape(len(edge_df),1)},
            adjacency = tfgnn.Adjacency.from_indices(
                source = ("People", np.array(edge_df['Name1_idx'], dtype='int32')),
                target = ("People", np.array(edge_df['Name2_idx'], dtype='int32')))),
        'Watched': tfgnn.EdgeSet.from_fields(
            sizes = [len(watched_df)],
            features = {},
            adjacency = tfgnn.Adjacency.from_indices(
                source = ("People", np.array(watched_df['Name_idx'], dtype='int32')),
                target = ("Movies", np.array(watched_df['Movie_idx'], dtype='int32'))))
  })

Забележка: Бъдете много внимателни с вашите dtypes и форми. Всякакви отклонения ще доведат до грешки или проблеми с обучението. Единствените поддържани dtypes са „int32“, „float32“ и „string“. Ако имате проблеми, моля, вижте раздела за отстраняване на неизправности в края на тази статия.

Може би сте забелязали, че тензорът на графиката е насочен с източник и цел. Това може да е добре за Сам, който гледа филм, но комуникацията е двупосочна. Когато Сам говори с Ейми, Ейми също говори със Сам. За двупосочни данни ще искате да дублирате тези ръбове (с обърнати източник и цел), за да посочите и двете посоки на потока от данни.

С тази основа вече сме готови да преминем към правене на прогнози върху реален набор от данни.

Настройка на данните

Данните за тренировките са мрежа от мачове по американски футбол между колежи от Дивизия IA по време на редовния сезон есен на 2000 г., както са събрани
от М. Гирван и М. Нюман. Данните за възли включват имена на колежи и индекс на конференцията, към която принадлежат (напр. конференция 8 = Pac 10). Ръбовете включват имената на двата колежа, което показва, че е играна игра между тях. Данните могат да бъдат изтеглени по следния начин (вижте Google Colab, за да следвате):

import urllib.request
import io
import zipfile
import networkx as nx

url = "http://www-personal.umich.edu/~mejn/netdata/football.zip"
sock = urllib.request.urlopen(url)  # open URL
s = io.BytesIO(sock.read())  # read into BytesIO "file"
sock.close()

zf = zipfile.ZipFile(s)  # zipfile object
txt = zf.read("football.txt").decode()  # read info file
gml = zf.read("football.gml").decode()  # read gml data
# throw away bogus first line with # from mejn files
gml = gml.split("\n")[1:]
G = nx.parse_gml(gml)  # parse gml data
print(txt)

Графичен тензор от NetworkX

Нашите данни вече са в графика на NetworkX. Нека да видим как изглежда с възли, оцветени според това към коя конференция принадлежат.

cmap = {0:'#bd2309', 1:'#bbb12d',2:'#1480fa',3:'#14fa2f',4:'#faf214',
        5:'#2edfea',6:'#ea2ec4',7:'#ea2e40',8:'#577a4d',9:'#2e46c0',
        10:'#f59422',11:'#8086d9'}

colors = [cmap[G.nodes[n]['value']] for n in G.nodes()]
pos = nx.spring_layout(G, seed=1987)

nx.draw_networkx_edges(G, pos, alpha=0.2)
nx.draw_networkx_nodes(G, pos, nodelist=G.nodes(),
                       node_color=colors, node_size=100)

За нашия модел на възел ще се опитаме да предвидим конференцията, към която принадлежи дадено училище. За нашия крайен модел ще се опитаме да предвидим дали дадена игра е била игра в конференция. И двете прогнози ще бъдат оценени върху набор от данни за задържане.Как да направим това от NetworkX? Възможно е да се изгради тензор на графика директно от графика, като се използват тези функции за извличане на данните:

node_data = G.nodes(data=True)
edge_data = G.edges(data=True)

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

node_df = pd.DataFrame.from_dict(dict(G.nodes(data=True)), orient='index')
node_df.index.name = 'school'
node_df.columns = ['conference']

edge_df = nx.to_pandas_edgelist(G)

Инженеринг на функциите

Използвайки базовата графика, модел може да е в състояние да определи дали два колежа са в една и съща конференция въз основа на мрежата. Но как ще знае коя точно конференция? Как би могъл да научи разликите между конференциите без никакви данни за възел или край? За тази задача ще трябва да добавим още функции.

Какви функции трябва да съберем? Не съм експерт по колежански футбол, но бих си представил, че конференциите се събират въз основа на близост и ранг. Това ръководство е фокусирано върху TF-GNN, така че ще добавя тези нови функции с помощта на магия, но можете да намерите конкретния код в свързаната Google Colab.

За възли ще добавим географска ширина/дължина и ранг, победи и победи на конференции от предходната година (1999). Ние също така ще преобразуваме конферентната колона в 12 фиктивни променливи колони за прогнозиране на softmax.

За краищата ще изчислим разстоянието между училищата, ще добавим резултат за сходство на името (може би училища с едно и също състояние в името си е по-вероятно да бъдат в една и съща конференция) и целева стойност за игрите, които са игра в рамките на конференцията.

Нека визуализираме нашите данни с нашата нова информация (оранжевите ръбове показват конферентна игра). Определено изглежда, че географията поне играе роля при избора на конференция.

Създаване на тестови сплитове

Създаването на тренировъчен сплит е лесно; изключете задържаните възли и ръбове по същия начин, както обикновено. Задържаните данни обаче са малко по-различни от типичното приложение за машинно обучение. Тъй като цялостните връзки са важни за точна прогноза, крайната прогноза ще трябва да бъде върху цялата графика. След като бъде направена прогноза, резултатите могат да бъдат филтрирани до данните за задържане за окончателната оценка. Ще покажа този процес по-подробно на етапа на прогнозиране; ето как създавам разделянията за сега:

from sklearn.model_selection import train_test_split

node_train, node_test = train_test_split(node_df,test_size=0.15,random_state=42)
edge_train = edge_df.loc[~((edge_df['source'].isin(node_test.index)) | (edge_df['target'].isin(node_test.index)))]
edge_test = edge_df.loc[(edge_df['source'].isin(node_test.index)) | (edge_df['target'].isin(node_test.index))]

С нашите нови разделяния вече можем да правим нашите двупосочни корекции и да добавяме колоните с индекс на ръба.

def bidirectional(edge_df):
    reverse_df = edge_df.rename(columns={'source':'target','target':'source'})
    reverse_df = reverse_df[edge_df.columns]
    reverse_df = pd.concat([edge_df, reverse_df], ignore_index=True, axis=0)
    return reverse_df

def create_adj_id(node_df,edge_df):
    node_df = node_df.reset_index().reset_index()
    edge_df = pd.merge(edge_df,node_df[['school','index']].rename(columns={"index":"source_id"}),
                       how='left',left_on='source',right_on='school').drop(columns=['school'])
    edge_df = pd.merge(edge_df,node_df[['school','index']].rename(columns={"index":"target_id"}),
                       how='left',left_on='target',right_on='school').drop(columns=['school'])
    
    edge_df.dropna(inplace=True)
    return node_df, edge_df

edge_full_adj = bidirectional(edge_df)
edge_train_adj = bidirectional(edge_train)

node_full_adj,edge_full_adj = create_adj_id(node_df,edge_full_adj)
node_train_adj,edge_train_adj = create_adj_id(node_train,edge_train_adj)

Създаване на набор от данни TensorFlow

Вече сме готови да създадем нашите графични тензори, които ще трансформираме в набори от данни TensorFlow.

def create_graph_tensor(node_df,edge_df):
    graph_tensor = tfgnn.GraphTensor.from_pieces(
        node_sets = {
            "schools": tfgnn.NodeSet.from_fields(
                sizes = [len(node_df)],
                features ={
                    'Latitude': np.array(node_df['Latitude'], dtype='float32').reshape(len(node_df),1),
                    'Longitude': np.array(node_df['Longitude'], dtype='float32').reshape(len(node_df),1),
                    'Rank': np.array(node_df['Rank'], dtype='int32').reshape(len(node_df),1),
                    'Wins': np.array(node_df['Wins'], dtype='int32').reshape(len(node_df),1),
                    'Conf_wins': np.array(node_df['Conf_wins'], dtype='int32').reshape(len(node_df),1),
                    'conference': np.array(node_df.iloc[:,-12:], dtype='int32'),
                }),
        },
        edge_sets ={
            "games": tfgnn.EdgeSet.from_fields(
                sizes = [len(edge_df)],
                features = {
                    'name_sim_score': np.array(edge_df['name_sim_score'], dtype='float32').reshape(len(edge_df),1),
                    'euclidean_dist': np.array(edge_df['euclidean_dist'], dtype='float32').reshape(len(edge_df),1),
                    'conference_game': np.array(edge_df['conference_game'], dtype='int32').reshape(len(edge_df),1)
                },
                adjacency = tfgnn.Adjacency.from_indices(
                    source = ("schools", np.array(edge_df['source_id'], dtype='int32')),
                    target = ("schools", np.array(edge_df['target_id'], dtype='int32')),
                )),
        })
    return graph_tensor

full_tensor = create_graph_tensor(node_full_adj,edge_full_adj)
train_tensor = create_graph_tensor(node_train_adj,edge_train_adj)

Преди да създадем набора от данни, имаме нужда от функция, която ще раздели нашата графика на нашите данни за обучение и целта, която ще прогнозираме (показана като етикет по-долу). За нашия проблем с прогнозиране на възел ще направим „конференция“ наш етикет. Също така трябва да премахнем функцията „conference_game“ от набора от данни, тъй като това би създало проблем с изтичане на данни (т.е. измама).

def node_batch_merge(graph):
    graph = graph.merge_batch_to_components()
    node_features = graph.node_sets['schools'].get_features_dict()
    edge_features = graph.edge_sets['games'].get_features_dict()
    
    label = node_features.pop('conference')
    _ = edge_features.pop('conference_game')
    
    new_graph = graph.replace_features(
        node_sets={'schools':node_features},
        edge_sets={'games':edge_features})
    return new_graph, label

Ще направим обратното за нашия ръбов модел: ще премахнем функцията „конференция“ и ще отделим „conference_game“ като наша цел (етикет).

def edge_batch_merge(graph):
    graph = graph.merge_batch_to_components()
    node_features = graph.node_sets['schools'].get_features_dict()
    edge_features = graph.edge_sets['games'].get_features_dict()
    
    _ = node_features.pop('conference')
    label = edge_features.pop('conference_game')
    
    new_graph = graph.replace_features(
        node_sets={'schools':node_features},
        edge_sets={'games':edge_features})
    return new_graph, label

Вече можем да създадем нашия набор от данни и да го картографираме чрез функцията по-горе.

def create_dataset(graph,function):
    dataset = tf.data.Dataset.from_tensors(graph)
    dataset = dataset.batch(32)
    return dataset.map(function)

#Node Datasets
full_node_dataset = create_dataset(full_tensor,node_batch_merge)
train_node_dataset = create_dataset(train_tensor,node_batch_merge)

#Edge Datasets
full_edge_dataset = create_dataset(full_tensor,edge_batch_merge)
train_edge_dataset = create_dataset(train_tensor,edge_batch_merge)

Редът на тези процедури е изключително важен:
1. Създаваме нашия набор от данни от тензора на графиката.
2. Разделяме нашия набор от данни на партиди (прочетете размера на партидите).
3. Във функцията за карта ние обединяваме тези партиди обратно в една графика.
4. Разделяме/премахваме характеристиките според нуждите.

Моделът няма да се обучава (или неправилно), ако не следвате точно този ред.

Изграждане на модела

Имаме нашите набори от данни, сега най-забавната част! Първо, ние дефинираме входовете, използвайки нашата спецификация за набор от данни.

graph_spec = train_node_dataset.element_spec[0]
input_graph = tf.keras.layers.Input(type_spec=graph_spec)

Сега трябва да инициализираме нашите функции. Ще създадем функции за инициализиране на възлите и ръбовете. След това картографираме нашите функции чрез тези функции. За да опростя нещата, ще създам плътен слой за всяка функция.

def set_initial_node_state(node_set, node_set_name):
    features = [
        tf.keras.layers.Dense(32,activation="relu")(node_set['Latitude']),
        tf.keras.layers.Dense(32,activation="relu")(node_set['Longitude']),
        tf.keras.layers.Dense(32,activation="relu")(node_set['Rank']),
        tf.keras.layers.Dense(32,activation="relu")(node_set['Wins']),
        tf.keras.layers.Dense(32,activation="relu")(node_set['Conf_wins'])
    ]
    return tf.keras.layers.Concatenate()(features)

def set_initial_edge_state(edge_set, edge_set_name):
    features = [
        tf.keras.layers.Dense(32,activation="relu")(edge_set['name_sim_score']),
        tf.keras.layers.Dense(32,activation="relu")(edge_set['euclidean_dist'])
    ]
    return tf.keras.layers.Concatenate()(features)

graph = tfgnn.keras.layers.MapFeatures(
    node_sets_fn=set_initial_node_state,
    edge_sets_fn=set_initial_edge_state
)(input_graph)

Има много персонализиране, което може да се случи с тази предишна стъпка. Например, бихме могли да създадем вграждане на думи за характеристики на низове. Вероятно бихме могли да постигнем известна точност, като хешираме мрежа за географска ширина/дължина, вместо просто да използваме плътен слой. TensorFlow разполага с много опции.

Няколко неща, които трябва да отбележите:

  • Ако имате множество възли или ръбове, ще трябва да добавите „операции if“, за да приложите функции към правилния възел/ръб.
  • Възли или ръбове без функции също могат да бъдат инициализирани с функцията „MakeEmptyFeature“.
  • За проблем, ориентиран към възел, инициализирането на ръбове не е задължително (прочетете повече за центриране на възел срещу ръб).
  • Първият възел трябва да има поне една функция. Може да се наложи да създадете вграждане в индекс, ако нямате функции (резултатите вероятно няма да са много добри).
# Examples, do not use for this problem
def set_initial_node_state(node_set, node_set_name):
    if node_set_name == "node_1":
        return tf.keras.layers.Embedding(115,3)(node_set['id'])
    elif node_set_name == "node_2":
        return tfgnn.keras.layers.MakeEmptyFeature()(node_set)

graph = tfgnn.keras.layers.MapFeatures(
    node_sets_fn=set_initial_node_state)(input_graph)

Преди да разработим нашия цикъл за актуализиране, имаме нужда от още една помощна функция. Докато добавяме плътни слоеве, ще искаме да сме сигурни, че използваме L2 регулиране и/или отпадане (L1 също би работило).

def dense_layer(self,units=64,l2_reg=0.1,dropout=0.25,activation='relu'):
    regularizer = tf.keras.regularizers.l2(l2_reg)
    return tf.keras.Sequential([
        tf.keras.layers.Dense(units,
                              kernel_regularizer=regularizer,
                              bias_regularizer=regularizer),
        tf.keras.layers.Dropout(dropout)])

Модел на възел

Има няколко моделни архитектури, но графичните конволюционни мрежи са най-често срещаните (вижте други подходи, описани „тук“). Графичните навивки са подобни на навивките, които обикновено се използват при проблеми с компютърното зрение. Основната разлика е, че навивките на графиките работят върху нередовните данни, които намирате с графичните структури. Нека преминем към действителния код.

graph_updates = 3 # tunable parameter
for i in range(graph_updates):
    graph = tfgnn.keras.layers.GraphUpdate(
        node_sets = {
            'schools': tfgnn.keras.layers.NodeSetUpdate({
                'games': tfgnn.keras.layers.SimpleConv(
                    message_fn = dense_layer(32),
                    reduce_type="sum",
                    sender_edge_feature = tfgnn.HIDDEN_STATE,
                    receiver_tag=tfgnn.TARGET)},
                tfgnn.keras.layers.NextStateFromConcat(
                    dense_layer(64)))})(graph) #start here
    
    logits = tf.keras.layers.Dense(12,activation='softmax')(graph.node_sets["schools"][tfgnn.HIDDEN_STATE])

node_model = tf.keras.Model(input_graph, logits)

Кодът по-горе може да изглежда малко объркващ поради начина, по който работи подреждането на TensorFlow. Не забравяйте, че (графиката), означена с „#start here“ в края на функцията „GraphUpdate“, всъщност е входът за кода, който идва преди нея. Първоначално това (графика) се равнява на инициализираните характеристики, които картографирахме преди това. Входът се въвежда във функцията „GraphUpdate“, превръщайки се в новата (графика). С всеки цикъл „graph_updates“, предишният „GraphUpdate“ става вход за новия „GraphUpdate“ заедно с плътен слой, определен с функцията „NextStateFromConcat“. Тази диаграма трябва да помогне да се обясни:

Функцията „GraphUpdate“ просто актуализира посочените състояния (възел, ръб или контекст) и добавя следващ слой на състоянието. В този случай ние само актуализираме състоянията на възлите с „NodeSetUpdate“, но ще проучим ориентиран към ръба подход, когато работим върху нашия модел на ръба. С тази актуализация на възел прилагаме конволюционен слой по протежение на краищата, което позволява информацията да се подава към възела от съседни възли и ръбове. Броят на актуализациите на графиката е регулируем параметър, като всяка актуализация позволява на информацията да пътува от други възли. Например, трите актуализации, посочени в нашия случай, позволяват информацията да пътува от до три възела разстояние. След като графиката ни се актуализира, крайното състояние на възел става вход за нашата глава за прогнозиране, обозначена с „logits“. Тъй като предвиждаме 12 различни конференции, имаме плътен слой от 12 единици с активиране на softmax. Сега можем да компилираме модела.

node_model.compile(
    tf.keras.optimizers.Adam(learning_rate=0.01),
    loss = 'categorical_crossentropy',
    metrics = ['categorical_accuracy']
)

node_model.summary()

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

es = tf.keras.callbacks.EarlyStopping(
        monitor='val_loss',mode='min',verbose=1,
        patience=10,restore_best_weights=True)

node_model.fit(train_node_dataset.repeat(),
               validation_data=full_node_dataset,
               steps_per_epoch=10,
               epochs=1000,
               callbacks=[es])

Време е да видим как се справихме с помощта на node_model.predict(full_node_dataset) и отпечатването на резултатите върху карта с помощта на магия (вижте Google Colab).

Като цяло имахме уважаваните 88% точност (вижте Google Colab за параметрите на модела). На модела изглежда му е по-трудно за планинските щати. Гмуркането на по-дълбоко дава някои интересни прозрения. Например, моделът погрешно прогнозира, че Юта ще участва в конференцията Pac 10. На следващата година обаче Юта всъщност се присъедини към Pac 10. Напълно е възможно моделът да идентифицира правилно как трябва да бъдат нещата и ~12% грешка наистина е измерване на човешка непоследователност при създаването на конференции. Друг начин да мислите за това е чрез социална мрежа от приятели. Ако мрежата прогнозира, че двама души са приятели, когато никога не са се срещали, грешен ли е моделът или те са подходящи за приятелство? За много (или по-голямата част) проблеми с графиките тези „грешки“ са това, което наистина се опитвате да намерите. След това те могат да се използват за препоръчване на продукти за закупуване, филми за гледане, хора, с които трябва да се свържете и т.н.

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

def evaluate_node():
    ### Add raw prediction ####
    yhat = node_model.predict(full_node_dataset)
    yhat_df = node_full_adj.set_index('school').iloc[:,-12:].copy()
    yhat_df.iloc[:,:] = yhat
    
    ### Classify max of softmax output ###
    yhat_df = yhat_df.apply(lambda x: x == x.max(), axis=1).astype(int)
    
    ### Merge output back to single column ###
    yhat_df = yhat_df.dot(yhat_df.columns).to_frame().rename(columns={0:'conf_yhat'})
    yhat_df = yhat_df['conf_yhat'].str.replace('conf_', '').astype(int).to_frame()
    yhat_df['conf_actual'] = node_full_adj['conference']
    
    ### Filter down to test nodes ###
    yhat_df = yhat_df.loc[yhat_df.index.isin(params['testset'].index)]
    
    ### Calculate accuracy ###
    yhat_df['Accuracy'] = yhat_df['conf_yhat']==yhat_df['conf_actual']
    return yhat_df['Accuracy'].mean()

За този модел точността пада до ~72% (без паника, очаква се спад на набор от данни за задържане). Предвид ограниченото инженерство на функциите, само една година данни и 12 прогнози за изхода — тези резултати са разумни. При визуална проверка на картите по-долу (и сравнение с пълната карта по-горе), повечето от грешките изглеждат като прилични предположения.

Модел Edge

Сега ще се опитаме да предвидим дали дадена игра е игра в конференция. Вече дефинирахме нашите крайни набори от данни по-горе и повечето от стъпките могат да бъдат използвани повторно само с една промяна:

### Change to train_edge_dataset ###
graph_spec = train_edge_dataset.element_spec[0]
input_graph = tf.keras.layers.Input(type_spec=graph_spec)
graph = tfgnn.keras.layers.MapFeatures(
    node_sets_fn=set_initial_node_state,
    edge_sets_fn=set_initial_edge_state
)(input_graph)

Все пак трябва да направим няколко промени в актуализациите на графиката. Първо, трябва да добавим актуализация „edge_sets“ към нашата функция „GraphUpdate“. Оставянето в актуализацията на „node_sets“ не е задължително, но изглежда, че моделът се справя по-добре за мен, когато го запазя. След това ще преминем от GCN към подход на Graph Nets. Този метод третира ръбовете като първокласни граждани (т.е. изискан начин да се каже, че ще научат собствените си тегла, което е, което търсим). И накрая, трябва да актуализираме „logits“, за да бъде плътен слой за активиране на сигмоида с една единица, тъй като предвиждаме фиктивна променлива.

graph_updates = 3
for i in range(graph_updates):
    graph = tfgnn.keras.layers.GraphUpdate(
        edge_sets = {'games': tfgnn.keras.layers.EdgeSetUpdate(
            next_state = tfgnn.keras.layers.NextStateFromConcat(
                dense_layer(64,activation='relu')))},
        node_sets = {
            'schools': tfgnn.keras.layers.NodeSetUpdate({
                'games': tfgnn.keras.layers.Pool(
                    tag=tfgnn.TARGET,
                    reduce_type="sum",
                    feature_name = tfgnn.HIDDEN_STATE)},
                tfgnn.keras.layers.NextStateFromConcat(
                    dense_layer(64)))})(graph)

    logits = tf.keras.layers.Dense(1,activation='sigmoid')(graph.edge_sets['games'][tfgnn.HIDDEN_STATE])

edge_model = tf.keras.Model(input_graph, logits)

Този път компилираме модела, използвайки „binary_crossentropy“.

edge_model.compile(
    tf.keras.optimizers.Adam(learning_rate=0.01),
    loss = 'binary_crossentropy',
    metrics = ['Accuracy']
)

edge_model.summary()

И ние напасваме модела, използвайки същото обратно извикване, дефинирано в нашия проблем с възела.

edge_model.fit(train_edge_dataset.repeat(),
               validation_data=full_edge_dataset,
               steps_per_epoch=10,
               epochs=1000,
               callbacks=[es])

yhat = edge_model.predict(full_edge_dataset)
yhat_df = edge_full_adj.copy().set_index(['source','target'])
yhat_df['conf_game_yhat'] = yhat.round(0)
yhat_df = yhat_df.loc[yhat_df.index.isin(
    edge_test.set_index(['source','target']).index)]
yhat_df['loss'] = abs(yhat_df['conference_game'] - yhat_df['conf_game_yhat'])
loss = yhat_df['loss'].mean()
print("edge accuracy:",1 - loss)

Когато се оценява върху набора от данни за задържане, получаваме 85% точност в сравнение със средна стойност от 56%. Моделът си свърши работата и съм доволен от резултатите.

Модел на контекста

Този конкретен проблем няма контекстна стойност. Нека си представим, че сме разрязали графиката по-горе, така че да имаме отделна графика за всяка конференция. Тези нови графики ще показват всяка игра, играна за отбори в конференцията, и ще игнорират всички други игри. След това бихме могли да имаме стойности за всяка графика за това как е класирана конференцията. Сега можем да обучим модел да прави прогнози на ниво контекст.

Първо, трябва да добавим нашите контекстни стойности към графиката.

graph_tensor = tfgnn.GraphTensor.from_pieces(
    context = tfgnn.Context.from_fields(
        features ={
            <context_feature>
        }),
    node_sets = {
        ...

След това трябва да създадем нов набор от данни с контекста, картографиран към етикета.

def node_batch_merge(graph):
    graph = graph.merge_batch_to_components()
    context_features = graph.context.get_features_dict()
    label = context_features.pop('<context_feature>')
    new_graph = graph.replace_features(
        context=context_features)
    return new_graph, label

Имаме способността да зададем нашето първоначално състояние на контекста. В този случай ние предвиждаме тази функция, така че тя ще отсъства от нашите данни за обучение. За други модели контекстът може да бъде тренируема функция и може да бъде зададен така:

def set_initial_context_state(context):
    return tf.keras.layers.Dense(32,activation="relu")(context['<context_feature>'])

graph = tfgnn.keras.layers.MapFeatures(
    context_fn=set_initial_context_state,
    node_sets_fn=set_initial_node_state,
    edge_sets_fn=set_initial_edge_state
)(input_graph)

Отново можем по избор да добавим контекстна актуализация към „GraphUpdate“ (вижте по-долу). Не съм тествал този метод, така че можете да експериментирате.

graph = tfgnn.keras.layers.GraphUpdate(
    node_sets ={...},
    context = tfgnn.keras.layers.ContextUpdate({
        'schools': tfgnn.keras.layers.Pool(tfgnn.CONTEXT, "mean")},
        tfgnn.keras.layers.NextStateFromConcat(tf.keras.layers.Dense(128))))

И накрая, актуализираме нашите „логисти“ за прогнозиране на контекста

logits = tfgnn.keras.layers.Pool(tfgnn.CONTEXT, "mean",
                                 node_set_name="schools")(graph)

Отстраняване на грешки

Попаднах на много грешки и лошо обучени модели, които се опитваха да разберат кода по-горе. Въпреки че се опитах да запазя нещата достатъчно общи, за да се приложат към много различни проблеми, без съмнение ще попаднете на грешки, докато правите корекции на вашите данни. Номерът е да идентифицирате източника на грешката си. Най-добрият начин, който намерих за диагностициране на грешки, беше да създам схема на графика.

В нашия код по-горе изтеглихме схемата на графиката от нашия набор от данни. Можете обаче да изградите схема на графика директно. За нашия футболен пример схемата на графиката ще изглежда така:

graph_spec = tfgnn.GraphTensorSpec.from_piece_specs(
    context_spec = tfgnn.ContextSpec.from_field_specs(
        features_spec ={
            #Added as an example for context problems
            #"conf_rank": tf.TensorSpec(shape=(None,1), dtype=tf.float32),
        }),
    node_sets_spec={
        'schools':
            tfgnn.NodeSetSpec.from_field_specs(
                features_spec={
                    'Latitude': tf.TensorSpec((None, 1), tf.float32),
                    'Longitude': tf.TensorSpec((None, 1), tf.float32),
                    'Rank': tf.TensorSpec((None, 1), tf.int32),
                    'Wins': tf.TensorSpec((None, 1), tf.int32),
                    'Conf_wins': tf.TensorSpec((None, 1), tf.int32),
                    'conference': tf.TensorSpec((None, 12), tf.int32)
                },
                sizes_spec=tf.TensorSpec((1,), tf.int32))
    },
    edge_sets_spec={
        'games':
            tfgnn.EdgeSetSpec.from_field_specs(
                features_spec={
                    'name_sim_score': tf.TensorSpec((None, 1), tf.float32),
                    'euclidean_dist': tf.TensorSpec((None, 1), tf.float32),
                    'conference_game': tf.TensorSpec((None, 1), tf.int32)
                },
                sizes_spec=tf.TensorSpec((1,), tf.int32),
                adjacency_spec=tfgnn.AdjacencySpec.from_incident_node_sets(
                    'schools', 'schools'))
    })

Можем да проверим дали нашата „graph_spec“ е поне валидна, като се опитаме да изградим и компилираме модела. Ако получите грешка, вероятно има проблем с вашите фигури на елементи или вашите функции „set_initial_…“. Ако работи, можете да проверите дали създадената от вас схема е съвместима с вашия „graph_tensor“.

graph_spec.is_compatible_with(full_tensor)

Ако е невярно, можете да отпечатате „full_tensor.spec“ и „graph_spec“, за да сравните всяка част, за да сте сигурни, че формите и dtypes са абсолютно еднакви. Можете също така да създадете произволно генериран графичен тензор директно от „graph_spec“.

random_graph = tfgnn.random_graph_tensor(graph_spec)

С тази „random_graph“ можете да опитате да обучите модел. Това трябва да ви помогне да определите дали грешката ви е със спецификацията или кода на модела. Ако не получите никакви грешки, можете да отпечатате стойностите на „random_graph“, за да видите как резултатите се сравняват с вашия „graph_tensor“.

print("Nodes:",random_graph.node_sets['schools'].features)
print("Edges:",random_graph.edge_sets['games'].features)
print("Context:",random_graph.context.features)

Тези стъпки трябва да ви позволят да проследите повечето проблеми, с които се сблъсквате.

Настройка на параметрите

Успешно поправихме всички грешки, които имахме, и обучихме модел. Сега искаме да настроим нашите хиперпараметри за точен модел. Избраният от мен тунер е библиотеката Hyperopt поради лесната й употреба и интегрираната байесова оптимизация. Но първо искаме да преобразуваме нашия код за моделиране по-горе в клас с променливи.

class GCNN:
    def __init__(self,params):
        self.params = params
        
    def set_initial_node_state(self, node_set, node_set_name):
        features = [
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(node_set['Latitude']),
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(node_set['Longitude']),
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(node_set['Rank']),
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(node_set['Wins']),
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(node_set['Conf_wins'])
        ]
        return tf.keras.layers.Concatenate()(features)

    def set_initial_edge_state(self, edge_set, edge_set_name):
        features = [
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(edge_set['name_sim_score']),
            tf.keras.layers.Dense(self.params['feature_dim'],activation="relu")(edge_set['euclidean_dist'])
        ]
        return tf.keras.layers.Concatenate()(features)

    def dense_layer(self,units=64):
        regularizer = tf.keras.regularizers.l2(self.params['l2_reg'])
        return tf.keras.Sequential([
            tf.keras.layers.Dense(units,
                                  kernel_regularizer=regularizer,
                                  bias_regularizer=regularizer,
                                  activation='relu'),
            tf.keras.layers.Dropout(self.params['dropout'])])

    def build_model(self):
        input_graph = tf.keras.layers.Input(type_spec=self.params['trainset'].element_spec[0])
        graph = tfgnn.keras.layers.MapFeatures(
            node_sets_fn=self.set_initial_node_state,
            edge_sets_fn=self.set_initial_edge_state
        )(input_graph)
        
        if self.params['loss']=='categorical_crossentropy':
            for i in range(self.params['graph_updates']):
                graph = tfgnn.keras.layers.GraphUpdate(
                    node_sets = {
                        'schools': tfgnn.keras.layers.NodeSetUpdate({
                            'games': tfgnn.keras.layers.SimpleConv(
                                message_fn = self.dense_layer(self.params['message_dim']),
                                reduce_type="sum",
                                receiver_tag=tfgnn.TARGET)},
                            tfgnn.keras.layers.NextStateFromConcat(
                                self.dense_layer(self.params['next_state_dim'])))})(graph)
            logits = tf.keras.layers.Dense(12,activation='softmax')(graph.node_sets['schools'][tfgnn.HIDDEN_STATE])
        else:
            for i in range(self.params['graph_updates']):
                graph = tfgnn.keras.layers.GraphUpdate(
                    edge_sets = {'games': tfgnn.keras.layers.EdgeSetUpdate(
                        next_state = tfgnn.keras.layers.NextStateFromConcat(
                            self.dense_layer(self.params['next_state_dim'])))},
                    node_sets = {
                        'schools': tfgnn.keras.layers.NodeSetUpdate({
                            'games': tfgnn.keras.layers.SimpleConv(
                                message_fn = self.dense_layer(self.params['message_dim']),
                                reduce_type="sum",
                                receiver_tag=tfgnn.TARGET)},
                            tfgnn.keras.layers.NextStateFromConcat(
                                self.dense_layer(self.params['next_state_dim'])))})(graph)
            logits = tf.keras.layers.Dense(1,activation='sigmoid')(graph.edge_sets['games'][tfgnn.HIDDEN_STATE])
        return tf.keras.Model(input_graph, logits)
        
    def train_model(self,trial=True):
        model = self.build_model()
        
        model.compile(tf.keras.optimizers.Adam(learning_rate=self.params['learning_rate']),
                      loss=self.params['loss'],
                      metrics=['Accuracy'])
        
        callbacks = [tf.keras.callbacks.EarlyStopping(monitor='val_loss',
                                                      mode='min',
                                                      verbose=1,
                                                      patience=self.params['patience'],
                                                      restore_best_weights=True)]
        
        model.fit(self.params['trainset'].repeat(),
                  validation_data=self.params['full_dataset'],
                  steps_per_epoch=self.params['steps_per_epoch'],
                  epochs=self.params['epochs'],
                  verbose=0,
                  callbacks = callbacks)
        
        loss = self.evaluate_model(model,trial=trial)
            
        if trial == True:
            sys.stdout.flush()
            hypt_params = {
                'graph_updates':self.params['graph_updates'],
                'feature_dim':self.params['feature_dim'],
                'next_state_dim':self.params['next_state_dim'],
                'message_dim':self.params['message_dim'],
                'l2_reg':self.params['l2_reg'],
                'dropout':self.params['dropout'],
                'learning_rate':self.params['learning_rate']}
            print(hypt_params,'loss:',loss)
            return {'loss': loss, 'status': STATUS_OK}
        else:
            print('loss:',loss)
            return model
        
    def evaluate_model(self,model,trial=True):
        if self.params['loss'] == 'categorical_crossentropy':
            yhat = model.predict(full_node_dataset)
            yhat_df = node_full_adj.set_index('school').iloc[:,-12:].copy()
            yhat_df.iloc[:,:] = yhat
            yhat_df = yhat_df.apply(lambda x: x == x.max(), axis=1).astype(int)
            yhat_df = yhat_df.dot(yhat_df.columns).to_frame().rename(columns={0:'conf_yhat'})
            yhat_df = yhat_df['conf_yhat'].str.replace('conf_', '').astype(int).to_frame()
            yhat_df['conf_actual'] = node_full_adj.set_index('school')['conference']
            yhat_df = yhat_df.loc[yhat_df.index.isin(node_test.index)]
            yhat_df['Accuracy'] = yhat_df['conf_yhat']==yhat_df['conf_actual']
            loss = 1 - yhat_df['Accuracy'].mean()
        else:
            yhat = model.predict(full_edge_dataset)
            yhat_df = edge_full_adj.copy().set_index(['source','target'])
            yhat_df['conf_game_yhat'] = yhat.round(0)
            yhat_df = yhat_df.loc[yhat_df.index.isin(
                edge_test.set_index(['source','target']).index)]
            yhat_df['loss'] = abs(yhat_df['conference_game'] - yhat_df['conf_game_yhat'])
            loss = yhat_df['loss'].mean()
        return loss

Сега дефинираме нашите параметри. За нашите параметри за настройка можем или изрично да дефинираме стойността (напр. „отпадане“: 0,1), или да дефинираме пространството, с което Hyperopt да експериментира, както направих по-долу. „hp.choice“ ще избира между опциите, които сте посочили, докато „hp.uniform“ ще избира опции между две стойности. Има много други налични опции в документацията на Hyperopt.

params = {
    ### Tuning parameters ###
    'graph_updates': hp.choice('graph_updates',[2,3,4]),
    'feature_dim': hp.choice('feature_dim',[16,32,64,128]),
    'message_dim': hp.choice('message_dim',[16,32,64,128]),
    'next_state_dim': hp.choice('next_state_dim',[16,32,64,128]),
    'l2_reg': hp.uniform('l2_reg',0.0,0.3),
    'dropout': hp.choice('dropout',[0,0.125,0.25,0.375,0.5]),
    'learning_rate': hp.uniform('learning_rate',0.0,0.1),
    
    ### Static parameters ###
    'loss': 'categorical_crossentropy',
    'epochs': 1000,
    'steps_per_epoch':10, ### This could also be a tuned parameter
    'patience':10,
    'trainset':train_node_dataset,
    'full_dataset':full_node_dataset
}

След това дефинираме помощна функция и я включваме в „fmin“ заедно с нашите параметри. Всяка оценка е обучен модел, така че това може да отнеме известно време в зависимост от вашия хардуер. Помислете да правите по-малко „max_evals“, ако е твърде бавно за вас. Моето лично основно правило е ~15 оценки на настроен параметър, така че бих дефинирал изрично някои от параметрите във връзка със спада в броя на оценките.

from hyperopt import fmin, tpe, hp, STATUS_OK, Trials

def tune_model(params):
    return GCNN(params).train_model()

best = fmin(tune_model, params, algo=tpe.suggest, 
            max_evals=100, trials=Trials())

Сега, когато разполагаме с най-добрите си хиперпараметри, можем да обучим окончателния си модел (ЗАБЕЛЕЖКА: вашата точност ще бъде малко по-различна поради начина, по който TensorFlow произволно инициализира своите тегла).

### Perameters from my hyperopt run ###
best = {'graph_updates': 4,
        'feature_dim': 64,
        'next_state_dim': 32,
        'message_dim': 128,
        'l2_reg': 0.095,
        'dropout': 0,
        'learning_rate': 0.0025
}

node_params = params
for param, value in best.items():
    node_params[param] = value

node_model = GCNN(node_params).train_model(trial=False)

Можем да настроим и обучим нашия модел edge с няколко леки корекции:

params['loss'] = 'binary_crossentropy'
params['trainset'] = train_edge_dataset
params['full_dataset'] = full_edge_dataset

best = fmin(tune_model, params, algo=tpe.suggest, 
            max_evals=100, trials=Trials())
### Perameters from my hyperopt run ###
best = {'graph_updates': 4,
        'feature_dim': 64,
        'next_state_dim': 32,
        'message_dim': 128,
        'l2_reg': 0.095,
        'dropout': 0,
        'learning_rate': 0.0025
}

edge_params = params
for param, value in best.items():
    edge_params[param] = value
    
edge_model = GCNN(edge_params).train_model(trial=False)

Последни мисли

Изследванията на GNN са все още в начален стадий. Вероятно ще бъдат открити нови методи за моделиране. Тъй като TF-GNN все още е в алфа състояние, има голям шанс да има някои промени в кода през годините. Моля, коментирайте по-долу, ако откриете промени или грешки, които все още не съм коригирал, и ще актуализирам това ръководство възможно най-добре. Ако тази статия не ви е харесала, можете да направите аналогия между мен и вашия любим исторически диктатор в коментарите. В противен случай пляскане или хубав коментар ще бъдат оценени.

Надявам се, че това ръководство може да бъде отправна точка за повече хора да навлязат в тази област и да експериментират. Считайте това за своя възможност да бъдете в началото на следващата вълна на ИИ!

За мен

Аз съм старши учен по данни и на непълно работно време на свободна практика с над 12 години опит. Винаги се стремя да се свържа, така че, моля, не се колебайте да:

Моля, не се колебайте да коментирате по-долу, ако имате въпроси.