├── GPFedRec_framework.png ├── mlp.py ├── README.md ├── metrics.py ├── utils.py ├── data.py ├── train.py └── engine.py /GPFedRec_framework.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Zhangcx19/GPFedRec/HEAD/GPFedRec_framework.png -------------------------------------------------------------------------------- /mlp.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from engine import Engine 3 | from utils import use_cuda, resume_checkpoint 4 | 5 | 6 | class MLP(torch.nn.Module): 7 | def __init__(self, config): 8 | super(MLP, self).__init__() 9 | self.config = config 10 | self.num_users = config['num_users'] 11 | self.num_items = config['num_items'] 12 | self.latent_dim = config['latent_dim'] 13 | 14 | self.embedding_user = torch.nn.Embedding(num_embeddings=1, embedding_dim=self.latent_dim) 15 | self.embedding_item = torch.nn.Embedding(num_embeddings=self.num_items, embedding_dim=self.latent_dim) 16 | 17 | self.fc_layers = torch.nn.ModuleList() 18 | for idx, (in_size, out_size) in enumerate(zip(config['layers'][:-1], config['layers'][1:])): 19 | self.fc_layers.append(torch.nn.Linear(in_size, out_size)) 20 | 21 | self.affine_output = torch.nn.Linear(in_features=config['layers'][-1], out_features=1) 22 | self.logistic = torch.nn.Sigmoid() 23 | 24 | def forward(self, item_indices): 25 | user_embedding = self.embedding_user(torch.LongTensor([0 for i in range(len(item_indices))]).cuda()) 26 | item_embedding = self.embedding_item(item_indices) 27 | vector = torch.cat([user_embedding, item_embedding], dim=-1) # the concat latent vector 28 | for idx, _ in enumerate(range(len(self.fc_layers))): 29 | vector = self.fc_layers[idx](vector) 30 | vector = torch.nn.ReLU()(vector) 31 | # vector = torch.nn.BatchNorm1d()(vector) 32 | # vector = torch.nn.Dropout(p=0.5)(vector) 33 | logits = self.affine_output(vector) 34 | rating = self.logistic(logits) 35 | return rating 36 | 37 | def init_weight(self): 38 | pass 39 | 40 | 41 | class MLPEngine(Engine): 42 | """Engine for training & evaluating GMF model""" 43 | def __init__(self, config): 44 | self.model = MLP(config) 45 | if config['use_cuda'] is True: 46 | use_cuda(True, config['device_id']) 47 | self.model.cuda() 48 | super(MLPEngine, self).__init__(config) 49 | print(self.model) 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GPFedRec: Graph-Guided Personalization for Federated Recommendation 2 | Code for kdd-24 paper: [GPFedRec: Graph-Guided Personalization for Federated Recommendation](https://arxiv.org/pdf/2305.07866). 3 | 4 | ## Abatract 5 | The federated recommendation system is an emerging AI service architecture that provides recommendation services in a privacy-preserving manner. Using user-relation graphs to enhance federated recommendations is a promising topic. However, it is still an open challenge to construct the user-relation graph while preserving data locality-based privacy protection in federated settings. Inspired by a simple motivation, similar users share a similar vision (embeddings) to the same item set, this paper proposes a novel Graph-guided Personalization for Federated Recommendation (GPFedRec). The proposed method constructs a user-relation graph from user-specific personalized item embeddings at the server without accessing the users’ interaction records. The personalized item embedding is locally fine-tuned on each device, and then a user-relation graph will be constructed by measuring the similarity among client-specific item embeddings. Without accessing users’ historical interactions, we embody the data locality-based privacy protection of vanilla federated learning. Furthermore, a graph-guided aggregation mechanism is designed to leverage the user-relation graph and federated optimization framework simultaneously. Extensive experiments on five benchmark datasets demonstrate GPFedRec’s superior performance. The in-depth study validates that GPFedRec can generally improve existing federated recommendation methods as a plugin while keeping user privacy safe. 6 | 7 | ![](https://github.com/Zhangcx19/GPFedRec/blob/main/GPFedRec_framework.png) 8 | **Figure:** 9 | The proposed GPFedRec framework. 10 | 11 | ## Preparations before running the code 12 | mkdir log 13 | 14 | mkdir sh_result 15 | 16 | ## Running the code 17 | python train.py 18 | 19 | ## Citation 20 | If you find this project helpful, please consider to cite the following paper: 21 | 22 | ``` 23 | @article{zhang2023graph, 24 | title={Graph-guided Personalization for Federated Recommendation}, 25 | author={Zhang, Chunxu and Long, Guodong and Zhou, Tianyi and Yan, Peng and Zhang, Zijjian and Yang, Bo}, 26 | journal={arXiv preprint arXiv:2305.07866}, 27 | year={2023} 28 | } 29 | ``` 30 | -------------------------------------------------------------------------------- /metrics.py: -------------------------------------------------------------------------------- 1 | import math 2 | import pandas as pd 3 | 4 | 5 | class MetronAtK(object): 6 | def __init__(self, top_k): 7 | self._top_k = top_k 8 | self._subjects = None # Subjects which we ran evaluation on 9 | 10 | @property 11 | def top_k(self): 12 | return self._top_k 13 | 14 | @top_k.setter 15 | def top_k(self, top_k): 16 | self._top_k = top_k 17 | 18 | @property 19 | def subjects(self): 20 | return self._subjects 21 | 22 | @subjects.setter 23 | def subjects(self, subjects): 24 | """ 25 | args: 26 | subjects: list, [test_users, test_items, test_scores, negative users, negative items, negative scores] 27 | """ 28 | assert isinstance(subjects, list) 29 | test_users, test_items, test_scores = subjects[0], subjects[1], subjects[2] 30 | neg_users, neg_items, neg_scores = subjects[3], subjects[4], subjects[5] 31 | # the golden set 32 | test = pd.DataFrame({'user': test_users, 33 | 'test_item': test_items, 34 | 'test_score': test_scores}) 35 | # the full set 36 | full = pd.DataFrame({'user': neg_users + test_users, 37 | 'item': neg_items + test_items, 38 | 'score': neg_scores + test_scores}) 39 | full = pd.merge(full, test, on=['user'], how='left') 40 | # rank the items according to the scores for each user 41 | full['rank'] = full.groupby('user')['score'].rank(method='first', ascending=False) 42 | full.sort_values(['user', 'rank'], inplace=True) 43 | self._subjects = full 44 | 45 | def cal_hit_ratio(self): 46 | """Hit Ratio @ top_K""" 47 | full, top_k = self._subjects, self._top_k 48 | top_k = full[full['rank']<=top_k] 49 | test_in_top_k =top_k[top_k['test_item'] == top_k['item']] # golden items hit in the top_K items 50 | return len(test_in_top_k) * 1.0 / full['user'].nunique() 51 | 52 | def cal_ndcg(self): 53 | full, top_k = self._subjects, self._top_k 54 | top_k = full[full['rank']<=top_k] 55 | test_in_top_k =top_k[top_k['test_item'] == top_k['item']] 56 | test_in_top_k['ndcg'] = test_in_top_k['rank'].apply(lambda x: math.log(2) / math.log(1 + x)) # the rank starts from 1 57 | return test_in_top_k['ndcg'].sum() * 1.0 / full['user'].nunique() 58 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | """ 2 | Some handy functions for pytroch model training ... 3 | """ 4 | import torch 5 | import numpy as np 6 | import copy 7 | from sklearn.metrics import pairwise_distances 8 | import logging 9 | import math 10 | 11 | 12 | # Checkpoints 13 | def save_checkpoint(model, model_dir): 14 | torch.save(model.state_dict(), model_dir) 15 | 16 | 17 | def resume_checkpoint(model, model_dir, device_id): 18 | state_dict = torch.load(model_dir, 19 | map_location=lambda storage, loc: storage.cuda(device=device_id)) # ensure all storage are on gpu 20 | model.load_state_dict(state_dict) 21 | 22 | 23 | # Hyper params 24 | def use_cuda(enabled, device_id=0): 25 | if enabled: 26 | assert torch.cuda.is_available(), 'CUDA is not available' 27 | torch.cuda.set_device(device_id) 28 | 29 | 30 | def use_optimizer(network, params): 31 | if params['optimizer'] == 'sgd': 32 | optimizer = torch.optim.SGD(network.parameters(), 33 | lr=params['sgd_lr'], 34 | momentum=params['sgd_momentum'], 35 | weight_decay=params['l2_regularization']) 36 | elif params['optimizer'] == 'adam': 37 | optimizer = torch.optim.Adam(network.parameters(), 38 | lr=params['lr'], 39 | weight_decay=params['l2_regularization']) 40 | elif params['optimizer'] == 'rmsprop': 41 | optimizer = torch.optim.RMSprop(network.parameters(), 42 | lr=params['rmsprop_lr'], 43 | alpha=params['rmsprop_alpha'], 44 | momentum=params['rmsprop_momentum']) 45 | return optimizer 46 | 47 | 48 | def construct_user_relation_graph_via_item(round_user_params, item_num, latent_dim, similarity_metric): 49 | # prepare the item embedding array. 50 | item_embedding = np.zeros((len(round_user_params), item_num * latent_dim), dtype='float32') 51 | for user in round_user_params.keys(): 52 | item_embedding[user] = round_user_params[user]['embedding_item.weight'].numpy().flatten() 53 | # construct the user relation graph. 54 | adj = pairwise_distances(item_embedding, metric=similarity_metric) 55 | if similarity_metric == 'cosine': 56 | return adj 57 | else: 58 | return -adj 59 | 60 | 61 | def select_topk_neighboehood(user_realtion_graph, neighborhood_size, neighborhood_threshold): 62 | topk_user_relation_graph = np.zeros(user_realtion_graph.shape, dtype='float32') 63 | if neighborhood_size > 0: 64 | for user in range(user_realtion_graph.shape[0]): 65 | user_neighborhood = user_realtion_graph[user] 66 | topk_indexes = user_neighborhood.argsort()[-neighborhood_size:][::-1] 67 | for i in topk_indexes: 68 | topk_user_relation_graph[user][i] = 1/neighborhood_size 69 | else: 70 | similarity_threshold = np.mean(user_realtion_graph)*neighborhood_threshold 71 | for i in range(user_realtion_graph.shape[0]): 72 | high_num = np.sum(user_realtion_graph[i] > similarity_threshold) 73 | if high_num > 0: 74 | for j in range(user_realtion_graph.shape[1]): 75 | if user_realtion_graph[i][j] > similarity_threshold: 76 | topk_user_relation_graph[i][j] = 1/high_num 77 | else: 78 | topk_user_relation_graph[i][i] = 1 79 | 80 | return topk_user_relation_graph 81 | 82 | 83 | def MP_on_graph(round_user_params, item_num, latent_dim, topk_user_relation_graph, layers): 84 | # prepare the item embedding array. 85 | item_embedding = np.zeros((len(round_user_params), item_num*latent_dim), dtype='float32') 86 | for user in round_user_params.keys(): 87 | item_embedding[user] = round_user_params[user]['embedding_item.weight'].numpy().flatten() 88 | 89 | # aggregate item embedding via message passing. 90 | aggregated_item_embedding = np.matmul(topk_user_relation_graph, item_embedding) 91 | for layer in range(layers-1): 92 | aggregated_item_embedding = np.matmul(topk_user_relation_graph, aggregated_item_embedding) 93 | 94 | # reconstruct item embedding. 95 | item_embedding_dict = {} 96 | for user in round_user_params.keys(): 97 | item_embedding_dict[user] = torch.from_numpy(aggregated_item_embedding[user].reshape(item_num, latent_dim)) 98 | item_embedding_dict['global'] = sum(item_embedding_dict.values())/len(round_user_params) 99 | return item_embedding_dict 100 | 101 | 102 | def initLogging(logFilename): 103 | """Init for logging 104 | """ 105 | logging.basicConfig( 106 | level = logging.DEBUG, 107 | format='%(asctime)s-%(levelname)s-%(message)s', 108 | datefmt = '%y-%m-%d %H:%M', 109 | filename = logFilename, 110 | filemode = 'w'); 111 | console = logging.StreamHandler() 112 | console.setLevel(logging.INFO) 113 | formatter = logging.Formatter('%(asctime)s-%(levelname)s-%(message)s') 114 | console.setFormatter(formatter) 115 | logging.getLogger('').addHandler(console) 116 | 117 | 118 | def compute_regularization(model, parameter_label): 119 | reg_fn = torch.nn.MSELoss(reduction='mean') 120 | for name, param in model.named_parameters(): 121 | if name == 'embedding_item.weight': 122 | reg_loss = reg_fn(param, parameter_label) 123 | return reg_loss 124 | -------------------------------------------------------------------------------- /data.py: -------------------------------------------------------------------------------- 1 | import torch 2 | import random 3 | import pandas as pd 4 | from copy import deepcopy 5 | from torch.utils.data import DataLoader, Dataset 6 | 7 | random.seed(0) 8 | 9 | class UserItemRatingDataset(Dataset): 10 | """Wrapper, convert Tensor into Pytorch Dataset""" 11 | 12 | def __init__(self, user_tensor, item_tensor, target_tensor): 13 | """ 14 | args: 15 | 16 | target_tensor: torch.Tensor, the corresponding rating for pair 17 | """ 18 | self.user_tensor = user_tensor 19 | self.item_tensor = item_tensor 20 | self.target_tensor = target_tensor 21 | 22 | def __getitem__(self, index): 23 | return self.user_tensor[index], self.item_tensor[index], self.target_tensor[index] 24 | 25 | def __len__(self): 26 | return self.user_tensor.size(0) 27 | 28 | class SampleGenerator(object): 29 | """Construct dataset for NCF""" 30 | 31 | def __init__(self, ratings): 32 | """ 33 | args: 34 | ratings: pd.DataFrame, which contains 4 columns = ['userId', 'itemId', 'rating', 'timestamp'] 35 | """ 36 | assert 'userId' in ratings.columns 37 | assert 'itemId' in ratings.columns 38 | assert 'rating' in ratings.columns 39 | 40 | self.ratings = ratings 41 | # explicit feedback using _normalize and implicit using _binarize 42 | # self.preprocess_ratings = self._normalize(ratings) 43 | self.preprocess_ratings = self._binarize(ratings) 44 | self.user_pool = set(self.ratings['userId'].unique()) 45 | self.item_pool = set(self.ratings['itemId'].unique()) 46 | # create negative item samples for NCF learning 47 | # 99 negatives for each user's test item 48 | self.negatives = self._sample_negative(ratings) 49 | # divide all ratings into train and test two dataframes, which consit of userId, itemId and rating three columns. 50 | self.train_ratings, self.val_ratings, self.test_ratings = self._split_loo(self.preprocess_ratings) 51 | 52 | def _normalize(self, ratings): 53 | """normalize into [0, 1] from [0, max_rating], explicit feedback""" 54 | ratings = deepcopy(ratings) 55 | max_rating = ratings.rating.max() 56 | ratings['rating'] = ratings.rating * 1.0 / max_rating 57 | return ratings 58 | 59 | def _binarize(self, ratings): 60 | """binarize into 0 or 1, imlicit feedback""" 61 | ratings = deepcopy(ratings) 62 | ratings['rating'][ratings['rating'] > 0] = 1.0 63 | return ratings 64 | 65 | def _split_loo(self, ratings): 66 | """leave one out train/test split """ 67 | ratings['rank_latest'] = ratings.groupby(['userId'])['timestamp'].rank(method='first', ascending=False) 68 | test = ratings[ratings['rank_latest'] == 1] 69 | val = ratings[ratings['rank_latest'] == 2] 70 | train = ratings[ratings['rank_latest'] > 2] 71 | assert train['userId'].nunique() == test['userId'].nunique() == val['userId'].nunique() 72 | assert len(train) + len(test) + len(val) == len(ratings) 73 | return train[['userId', 'itemId', 'rating']], val[['userId', 'itemId', 'rating']], test[['userId', 'itemId', 'rating']] 74 | 75 | def _sample_negative(self, ratings): 76 | """return all negative items & 100 sampled negative items""" 77 | interact_status = ratings.groupby('userId')['itemId'].apply(set).reset_index().rename( 78 | columns={'itemId': 'interacted_items'}) 79 | interact_status['negative_items'] = interact_status['interacted_items'].apply(lambda x: self.item_pool - x) 80 | interact_status['negative_samples'] = interact_status['negative_items'].apply(lambda x: random.sample(x, 198)) 81 | return interact_status[['userId', 'negative_items', 'negative_samples']] 82 | 83 | def store_all_train_data(self, num_negatives): 84 | """store all the train data as a list including users, items and ratings. each list consists of all users' 85 | information, where each sub-list stores a user's positives and negatives""" 86 | users, items, ratings = [], [], [] 87 | train_ratings = pd.merge(self.train_ratings, self.negatives[['userId', 'negative_items']], on='userId') 88 | train_ratings['negatives'] = train_ratings['negative_items'].apply(lambda x: random.sample(x, 89 | num_negatives)) # include userId, itemId, rating, negative_items and negatives five columns. 90 | single_user = [] 91 | user_item = [] 92 | user_rating = [] 93 | # split train_ratings into groups according to userId. 94 | grouped_train_ratings = train_ratings.groupby('userId') 95 | train_users = [] 96 | for userId, user_train_ratings in grouped_train_ratings: 97 | train_users.append(userId) 98 | user_length = len(user_train_ratings) 99 | for row in user_train_ratings.itertuples(): 100 | single_user.append(int(row.userId)) 101 | user_item.append(int(row.itemId)) 102 | user_rating.append(float(row.rating)) 103 | for i in range(num_negatives): 104 | single_user.append(int(row.userId)) 105 | user_item.append(int(row.negatives[i])) 106 | user_rating.append(float(0)) # negative samples get 0 rating 107 | assert len(single_user) == len(user_item) == len(user_rating) 108 | assert (1 + num_negatives) * user_length == len(single_user) 109 | users.append(single_user) 110 | items.append(user_item) 111 | ratings.append(user_rating) 112 | single_user = [] 113 | user_item = [] 114 | user_rating = [] 115 | assert len(users) == len(items) == len(ratings) == len(self.user_pool) 116 | assert train_users == sorted(train_users) 117 | return [users, items, ratings] 118 | 119 | @property 120 | def validate_data(self): 121 | """create validation data""" 122 | val_ratings = pd.merge(self.val_ratings, self.negatives[['userId', 'negative_samples']], on='userId') 123 | val_users, val_items, negative_users, negative_items = [], [], [], [] 124 | for row in val_ratings.itertuples(): 125 | val_users.append(int(row.userId)) 126 | val_items.append(int(row.itemId)) 127 | for i in range(int(len(row.negative_samples) / 2)): 128 | negative_users.append(int(row.userId)) 129 | negative_items.append(int(row.negative_samples[i])) 130 | assert len(val_users) == len(val_items) 131 | assert len(negative_users) == len(negative_items) 132 | assert 99 * len(val_users) == len(negative_users) 133 | assert val_users == sorted(val_users) 134 | return [torch.LongTensor(val_users), torch.LongTensor(val_items), torch.LongTensor(negative_users), 135 | torch.LongTensor(negative_items)] 136 | 137 | @property 138 | def test_data(self): 139 | """create evaluate data""" 140 | # return four lists, which consist userId or itemId. 141 | test_ratings = pd.merge(self.test_ratings, self.negatives[['userId', 'negative_samples']], on='userId') 142 | test_users, test_items, negative_users, negative_items = [], [], [], [] 143 | for row in test_ratings.itertuples(): 144 | test_users.append(int(row.userId)) 145 | test_items.append(int(row.itemId)) 146 | for i in range(int(len(row.negative_samples) / 2), len(row.negative_samples)): 147 | negative_users.append(int(row.userId)) 148 | negative_items.append(int(row.negative_samples[i])) 149 | assert len(test_users) == len(test_items) 150 | assert len(negative_users) == len(negative_items) 151 | assert 99 * len(test_users) == len(negative_users) 152 | assert test_users == sorted(test_users) 153 | return [torch.LongTensor(test_users), torch.LongTensor(test_items), torch.LongTensor(negative_users), 154 | torch.LongTensor(negative_items)] -------------------------------------------------------------------------------- /train.py: -------------------------------------------------------------------------------- 1 | import pandas as pd 2 | import numpy as np 3 | import datetime 4 | import os 5 | os.environ["CUDA_VISIBLE_DEVICES"] = "0,2" 6 | import argparse 7 | from mlp import MLPEngine 8 | from data import SampleGenerator 9 | from utils import * 10 | 11 | 12 | # Training settings 13 | parser = argparse.ArgumentParser() 14 | parser.add_argument('--alias', type=str, default='fedgraph') 15 | parser.add_argument('--clients_sample_ratio', type=float, default=1.0) 16 | parser.add_argument('--clients_sample_num', type=int, default=0) 17 | parser.add_argument('--num_round', type=int, default=100) 18 | parser.add_argument('--local_epoch', type=int, default=1) 19 | parser.add_argument('--neighborhood_size', type=int, default=0) 20 | parser.add_argument('--neighborhood_threshold', type=float, default=1.) 21 | parser.add_argument('--mp_layers', type=int, default=1) 22 | parser.add_argument('--similarity_metric', type=str, default='cosine') 23 | parser.add_argument('--reg', type=float, default=1.0) 24 | parser.add_argument('--lr_eta', type=int, default=80) 25 | parser.add_argument('--batch_size', type=int, default=256) 26 | parser.add_argument('--optimizer', type=str, default='sgd') 27 | parser.add_argument('--lr', type=float, default=0.1) 28 | parser.add_argument('--dataset', type=str, default='100k') 29 | parser.add_argument('--num_users', type=int) 30 | parser.add_argument('--num_items', type=int) 31 | parser.add_argument('--latent_dim', type=int, default=32) 32 | parser.add_argument('--num_negative', type=int, default=4) 33 | parser.add_argument('--layers', type=str, default='64, 32, 16, 8') 34 | parser.add_argument('--l2_regularization', type=float, default=0.) 35 | parser.add_argument('--dp', type=float, default=0.) 36 | parser.add_argument('--use_cuda', type=bool, default=True) 37 | parser.add_argument('--device_id', type=int, default=0) 38 | parser.add_argument('--model_dir', type=str, default='checkpoints/{}_Epoch{}_HR{:.4f}_NDCG{:.4f}.model') 39 | args = parser.parse_args() 40 | 41 | # Model. 42 | config = vars(args) 43 | if len(config['layers']) > 1: 44 | config['layers'] = [int(item) for item in config['layers'].split(',')] 45 | else: 46 | config['layers'] = int(config['layers']) 47 | if config['dataset'] == 'ml-1m': 48 | config['num_users'] = 6040 49 | config['num_items'] = 3706 50 | elif config['dataset'] == '100k': 51 | config['num_users'] = 943 52 | config['num_items'] = 1682 53 | elif config['dataset'] == 'lastfm-2k': 54 | config['num_users'] = 1600 55 | config['num_items'] = 12454 56 | elif config['dataset'] == 'amazon': 57 | config['num_users'] = 8072 58 | config['num_items'] = 11830 59 | else: 60 | pass 61 | engine = MLPEngine(config) 62 | 63 | # Logging. 64 | path = 'log/' 65 | current_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 66 | logname = os.path.join(path, current_time+'.txt') 67 | initLogging(logname) 68 | 69 | # Load Data 70 | dataset_dir = "data/" + config['dataset'] + "/" + "ratings.dat" 71 | if config['dataset'] == "ml-1m": 72 | rating = pd.read_csv(dataset_dir, sep='::', header=None, names=['uid', 'mid', 'rating', 'timestamp'], engine='python') 73 | elif config['dataset'] == "100k": 74 | rating = pd.read_csv(dataset_dir, sep=",", header=None, names=['uid', 'mid', 'rating', 'timestamp'], engine='python') 75 | elif config['dataset'] == "lastfm-2k": 76 | rating = pd.read_csv(dataset_dir, sep=",", header=None, names=['uid', 'mid', 'rating', 'timestamp'], engine='python') 77 | elif config['dataset'] == "amazon": 78 | rating = pd.read_csv(dataset_dir, sep=",", header=None, names=['uid', 'mid', 'rating', 'timestamp'], engine='python') 79 | rating = rating.sort_values(by='uid', ascending=True) 80 | else: 81 | pass 82 | # Reindex 83 | user_id = rating[['uid']].drop_duplicates().reindex() 84 | user_id['userId'] = np.arange(len(user_id)) 85 | rating = pd.merge(rating, user_id, on=['uid'], how='left') 86 | item_id = rating[['mid']].drop_duplicates() 87 | item_id['itemId'] = np.arange(len(item_id)) 88 | rating = pd.merge(rating, item_id, on=['mid'], how='left') 89 | rating = rating[['userId', 'itemId', 'rating', 'timestamp']] 90 | logging.info('Range of userId is [{}, {}]'.format(rating.userId.min(), rating.userId.max())) 91 | logging.info('Range of itemId is [{}, {}]'.format(rating.itemId.min(), rating.itemId.max())) 92 | 93 | # DataLoader for training 94 | sample_generator = SampleGenerator(ratings=rating) 95 | validate_data = sample_generator.validate_data 96 | test_data = sample_generator.test_data 97 | 98 | hit_ratio_list = [] 99 | ndcg_list = [] 100 | val_hr_list = [] 101 | val_ndcg_list = [] 102 | train_loss_list = [] 103 | test_loss_list = [] 104 | val_loss_list = [] 105 | best_val_hr = 0 106 | final_test_round = 0 107 | for round in range(config['num_round']): 108 | # break 109 | logging.info('-' * 80) 110 | logging.info('Round {} starts !'.format(round)) 111 | 112 | all_train_data = sample_generator.store_all_train_data(config['num_negative']) 113 | logging.info('-' * 80) 114 | logging.info('Training phase!') 115 | tr_loss = engine.fed_train_a_round(all_train_data, round_id=round) 116 | # break 117 | train_loss_list.append(tr_loss) 118 | 119 | logging.info('-' * 80) 120 | logging.info('Testing phase!') 121 | hit_ratio, ndcg, te_loss = engine.fed_evaluate(test_data) 122 | test_loss_list.append(te_loss) 123 | # break 124 | logging.info('[Testing Epoch {}] HR = {:.4f}, NDCG = {:.4f}'.format(round, hit_ratio, ndcg)) 125 | hit_ratio_list.append(hit_ratio) 126 | ndcg_list.append(ndcg) 127 | 128 | logging.info('-' * 80) 129 | logging.info('Validating phase!') 130 | val_hit_ratio, val_ndcg, v_loss = engine.fed_evaluate(validate_data) 131 | val_loss_list.append(v_loss) 132 | logging.info( 133 | '[Evluating Epoch {}] HR = {:.4f}, NDCG = {:.4f}'.format(round, val_hit_ratio, val_ndcg)) 134 | val_hr_list.append(val_hit_ratio) 135 | val_ndcg_list.append(val_ndcg) 136 | 137 | if val_hit_ratio >= best_val_hr: 138 | best_val_hr = val_hit_ratio 139 | final_test_round = round 140 | 141 | current_time = datetime.datetime.now().strftime('%Y-%m-%d %H-%M-%S') 142 | str = current_time + '-' + 'layers: ' + str(config['layers']) + '-' + 'lr: ' + str(config['lr']) + '-' + \ 143 | 'clients_sample_ratio: ' + str(config['clients_sample_ratio']) + '-' + 'num_round: ' + str(config['num_round']) \ 144 | + '-' 'neighborhood_size: ' + str(config['neighborhood_size']) + '-' + 'mp_layers: ' + str(config['mp_layers']) \ 145 | + '-' + 'negatives: ' + str(config['num_negative']) + '-' + 'lr_eta: ' + str(config['lr_eta']) + '-' + \ 146 | 'batch_size: ' + str(config['batch_size']) + '-' + 'hr: ' + str(hit_ratio_list[final_test_round]) + '-' \ 147 | + 'ndcg: ' + str(ndcg_list[final_test_round]) + '-' + 'best_round: ' + str(final_test_round) + '-' + \ 148 | 'similarity_metric: ' + str(config['similarity_metric']) + '-' + 'neighborhood_threshold: ' + \ 149 | str(config['neighborhood_threshold']) + '-' + 'reg: ' + str(config['reg']) 150 | file_name = "sh_result/"+'-'+config['dataset']+".txt" 151 | with open(file_name, 'a') as file: 152 | file.write(str + '\n') 153 | 154 | logging.info('fedgraph') 155 | logging.info('clients_sample_ratio: {}, lr_eta: {}, bz: {}, lr: {}, dataset: {}, layers: {}, negatives: {}, ' 156 | 'neighborhood_size: {}, neighborhood_threshold: {}, mp_layers: {}, similarity_metric: {}'. 157 | format(config['clients_sample_ratio'], config['lr_eta'], config['batch_size'], config['lr'], 158 | config['dataset'], config['layers'], config['num_negative'], config['neighborhood_size'], 159 | config['neighborhood_threshold'], config['mp_layers'], config['similarity_metric'])) 160 | 161 | logging.info('hit_list: {}'.format(hit_ratio_list)) 162 | logging.info('ndcg_list: {}'.format(ndcg_list)) 163 | logging.info('Best test hr: {}, ndcg: {} at round {}'.format(hit_ratio_list[final_test_round], 164 | ndcg_list[final_test_round], 165 | final_test_round)) 166 | -------------------------------------------------------------------------------- /engine.py: -------------------------------------------------------------------------------- 1 | import torch 2 | from torch.autograd import Variable 3 | from tensorboardX import SummaryWriter 4 | 5 | from utils import * 6 | from metrics import MetronAtK 7 | import random 8 | import copy 9 | from data import UserItemRatingDataset 10 | from torch.utils.data import DataLoader 11 | from torch.distributions.laplace import Laplace 12 | 13 | 14 | class Engine(object): 15 | """Meta Engine for training & evaluating NCF model 16 | 17 | Note: Subclass should implement self.model ! 18 | """ 19 | 20 | def __init__(self, config): 21 | self.config = config # model configuration 22 | self._metron = MetronAtK(top_k=10) 23 | # self._writer = SummaryWriter(log_dir='runs/{}'.format(config['alias'])) # tensorboard writer 24 | # self._writer.add_text('config', str(config), 0) 25 | self.server_model_param = {} 26 | self.client_model_params = {} 27 | # explicit feedback 28 | # self.crit = torch.nn.MSELoss() 29 | # implicit feedback 30 | self.crit = torch.nn.BCELoss() 31 | self.top_k = 10 32 | 33 | def instance_user_train_loader(self, user_train_data): 34 | """instance a user's train loader.""" 35 | dataset = UserItemRatingDataset(user_tensor=torch.LongTensor(user_train_data[0]), 36 | item_tensor=torch.LongTensor(user_train_data[1]), 37 | target_tensor=torch.FloatTensor(user_train_data[2])) 38 | return DataLoader(dataset, batch_size=self.config['batch_size'], shuffle=True) 39 | 40 | def fed_train_single_batch(self, model_client, batch_data, optimizers, user): 41 | """train a batch and return an updated model.""" 42 | users, items, ratings = batch_data[0], batch_data[1], batch_data[2] 43 | ratings = ratings.float() 44 | reg_item_embedding = copy.deepcopy(self.server_model_param['embedding_item.weight'][user].data) 45 | optimizer, optimizer_u, optimizer_i = optimizers 46 | if self.config['use_cuda'] is True: 47 | users, items, ratings = users.cuda(), items.cuda(), ratings.cuda() 48 | reg_item_embedding = reg_item_embedding.cuda() 49 | optimizer.zero_grad() 50 | optimizer_u.zero_grad() 51 | optimizer_i.zero_grad() 52 | ratings_pred = model_client(items) 53 | loss = self.crit(ratings_pred.view(-1), ratings) 54 | regularization_term = compute_regularization(model_client, reg_item_embedding) 55 | loss += self.config['reg'] * regularization_term 56 | loss.backward() 57 | optimizer.step() 58 | optimizer_u.step() 59 | optimizer_i.step() 60 | return model_client, loss.item() 61 | 62 | def aggregate_clients_params(self, round_user_params): 63 | """receive client models' parameters in a round, aggregate them and store the aggregated result for server.""" 64 | # construct the user relation graph via embedding similarity. 65 | user_relation_graph = construct_user_relation_graph_via_item(round_user_params, self.config['num_items'], 66 | self.config['latent_dim'], 67 | self.config['similarity_metric']) 68 | # select the top-k neighborhood for each user. 69 | topk_user_relation_graph = select_topk_neighboehood(user_relation_graph, self.config['neighborhood_size'], 70 | self.config['neighborhood_threshold']) 71 | # update item embedding via message passing. 72 | updated_item_embedding = MP_on_graph(round_user_params, self.config['num_items'], self.config['latent_dim'], 73 | topk_user_relation_graph, self.config['mp_layers']) 74 | self.server_model_param['embedding_item.weight'] = copy.deepcopy(updated_item_embedding) 75 | 76 | 77 | def fed_train_a_round(self, all_train_data, round_id): 78 | """train a round.""" 79 | # sample users participating in single round. 80 | num_participants = int(self.config['num_users'] * self.config['clients_sample_ratio']) 81 | participants = random.sample(range(self.config['num_users']), num_participants) 82 | # store users' model parameters of current round. 83 | round_participant_params = {} 84 | 85 | # initialize server parameters for the first round. 86 | if round_id == 0: 87 | self.server_model_param['embedding_item.weight'] = {} 88 | for user in participants: 89 | self.server_model_param['embedding_item.weight'][user] = copy.deepcopy(self.model.state_dict()['embedding_item.weight'].data.cpu()) 90 | self.server_model_param['embedding_item.weight']['global'] = copy.deepcopy(self.model.state_dict()['embedding_item.weight'].data.cpu()) 91 | # perform model updating for each participated user. 92 | for user in participants: 93 | # copy the client model architecture from self.model 94 | model_client = copy.deepcopy(self.model) 95 | # for the first round, client models copy initialized parameters directly. 96 | # for other rounds, client models receive updated user embedding and aggregated item embedding from server 97 | # and use local updated mlp parameters from last round. 98 | if round_id != 0: 99 | # for participated users, load local updated parameters. 100 | user_param_dict = copy.deepcopy(self.model.state_dict()) 101 | if user in self.client_model_params.keys(): 102 | for key in self.client_model_params[user].keys(): 103 | user_param_dict[key] = copy.deepcopy(self.client_model_params[user][key].data).cuda() 104 | user_param_dict['embedding_item.weight'] = copy.deepcopy(self.server_model_param['embedding_item.weight']['global'].data).cuda() 105 | model_client.load_state_dict(user_param_dict) 106 | # Defining optimizers 107 | # optimizer is responsible for updating mlp parameters. 108 | optimizer = torch.optim.SGD( 109 | [{"params": model_client.fc_layers.parameters()}, {"params": model_client.affine_output.parameters()}], 110 | lr=self.config['lr']) # MLP optimizer 111 | # optimizer_u is responsible for updating user embedding. 112 | optimizer_u = torch.optim.SGD(model_client.embedding_user.parameters(), 113 | lr=self.config['lr'] / self.config['clients_sample_ratio'] * self.config[ 114 | 'lr_eta'] - self.config['lr']) # User optimizer 115 | # optimizer_i is responsible for updating item embedding. 116 | optimizer_i = torch.optim.SGD(model_client.embedding_item.parameters(), 117 | lr=self.config['lr'] * self.config['num_items'] * self.config['lr_eta'] - 118 | self.config['lr']) # Item optimizer 119 | optimizers = [optimizer, optimizer_u, optimizer_i] 120 | # load current user's training data and instance a train loader. 121 | user_train_data = [all_train_data[0][user], all_train_data[1][user], all_train_data[2][user]] 122 | user_dataloader = self.instance_user_train_loader(user_train_data) 123 | model_client.train() 124 | # update client model. 125 | for epoch in range(self.config['local_epoch']): 126 | for batch_id, batch in enumerate(user_dataloader): 127 | assert isinstance(batch[0], torch.LongTensor) 128 | model_client, loss = self.fed_train_single_batch(model_client, batch, optimizers, user) 129 | # print('[User {}]'.format(user)) 130 | # obtain client model parameters. 131 | client_param = model_client.state_dict() 132 | # store client models' user embedding using a dict. 133 | self.client_model_params[user] = copy.deepcopy(client_param) 134 | for key in self.client_model_params[user].keys(): 135 | self.client_model_params[user][key] = self.client_model_params[user][key].data.cpu() 136 | # round_participant_params[user] = copy.deepcopy(self.client_model_params[user]) 137 | # del round_participant_params[user]['embedding_user.weight'] 138 | round_participant_params[user] = {} 139 | round_participant_params[user]['embedding_item.weight'] = copy.deepcopy(self.client_model_params[user]['embedding_item.weight']) 140 | round_participant_params[user]['embedding_item.weight'] += Laplace(0, self.config['dp']).expand(round_participant_params[user]['embedding_item.weight'].shape).sample() 141 | # aggregate client models in server side. 142 | self.aggregate_clients_params(round_participant_params) 143 | return participants 144 | 145 | def fed_evaluate(self, evaluate_data): 146 | # evaluate all client models' performance using testing data. 147 | test_users, test_items = evaluate_data[0], evaluate_data[1] 148 | negative_users, negative_items = evaluate_data[2], evaluate_data[3] 149 | # ratings for computing loss. 150 | temp = [0] * 100 151 | temp[0] = 1 152 | ratings = torch.FloatTensor(temp) 153 | if self.config['use_cuda'] is True: 154 | test_users = test_users.cuda() 155 | test_items = test_items.cuda() 156 | negative_users = negative_users.cuda() 157 | negative_items = negative_items.cuda() 158 | ratings = ratings.cuda() 159 | # store all users' test item prediction score. 160 | test_scores = None 161 | # store all users' negative items prediction scores. 162 | negative_scores = None 163 | all_loss = {} 164 | for user in range(self.config['num_users']): 165 | # load each user's mlp parameters. 166 | user_model = copy.deepcopy(self.model) 167 | user_param_dict = copy.deepcopy(self.model.state_dict()) 168 | if user in self.client_model_params.keys(): 169 | for key in self.client_model_params[user].keys(): 170 | user_param_dict[key] = copy.deepcopy(self.client_model_params[user][key].data).cuda() 171 | # user_param_dict['embedding_item.weight'] = copy.deepcopy( 172 | # self.server_model_param['embedding_item.weight']['global'].data).cuda() 173 | user_model.load_state_dict(user_param_dict) 174 | user_model.eval() 175 | with torch.no_grad(): 176 | # obtain user's positive test information. 177 | test_user = test_users[user: user + 1] 178 | test_item = test_items[user: user + 1] 179 | # obtain user's negative test information. 180 | negative_user = negative_users[user * 99: (user + 1) * 99] 181 | negative_item = negative_items[user * 99: (user + 1) * 99] 182 | # perform model prediction. 183 | test_score = user_model(test_item) 184 | negative_score = user_model(negative_item) 185 | if user == 0: 186 | test_scores = test_score 187 | negative_scores = negative_score 188 | else: 189 | test_scores = torch.cat((test_scores, test_score)) 190 | negative_scores = torch.cat((negative_scores, negative_score)) 191 | ratings_pred = torch.cat((test_score, negative_score)) 192 | loss = self.crit(ratings_pred.view(-1), ratings) 193 | all_loss[user] = loss.item() 194 | if self.config['use_cuda'] is True: 195 | test_users = test_users.cpu() 196 | test_items = test_items.cpu() 197 | test_scores = test_scores.cpu() 198 | negative_users = negative_users.cpu() 199 | negative_items = negative_items.cpu() 200 | negative_scores = negative_scores.cpu() 201 | self._metron.subjects = [test_users.data.view(-1).tolist(), 202 | test_items.data.view(-1).tolist(), 203 | test_scores.data.view(-1).tolist(), 204 | negative_users.data.view(-1).tolist(), 205 | negative_items.data.view(-1).tolist(), 206 | negative_scores.data.view(-1).tolist()] 207 | hit_ratio, ndcg = self._metron.cal_hit_ratio(), self._metron.cal_ndcg() 208 | return hit_ratio, ndcg, all_loss 209 | 210 | def save(self, alias, epoch_id, hit_ratio, ndcg): 211 | assert hasattr(self, 'model'), 'Please specify the exact model !' 212 | model_dir = self.config['model_dir'].format(alias, epoch_id, hit_ratio, ndcg) 213 | save_checkpoint(self.model, model_dir) 214 | --------------------------------------------------------------------------------