1# Copyright 2017 The TensorFlow Authors. All Rights Reserved. 2# 3# Licensed under the Apache License, Version 2.0 (the "License"); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14# ============================================================================== 15"""Tests for triplet_semihard_loss.""" 16from __future__ import absolute_import 17from __future__ import division 18from __future__ import print_function 19 20import numpy as np 21from tensorflow.contrib.losses.python import metric_learning as metric_loss_ops 22from tensorflow.python.framework import ops 23from tensorflow.python.framework import sparse_tensor 24from tensorflow.python.ops import math_ops 25from tensorflow.python.ops import nn 26from tensorflow.python.platform import test 27try: 28 # pylint: disable=g-import-not-at-top 29 from sklearn import datasets 30 from sklearn import metrics 31 HAS_SKLEARN = True 32except ImportError: 33 HAS_SKLEARN = False 34 35 36def pairwise_distance_np(feature, squared=False): 37 """Computes the pairwise distance matrix in numpy. 38 39 Args: 40 feature: 2-D numpy array of size [number of data, feature dimension] 41 squared: Boolean. If true, output is the pairwise squared euclidean 42 distance matrix; else, output is the pairwise euclidean distance matrix. 43 44 Returns: 45 pairwise_distances: 2-D numpy array of size 46 [number of data, number of data]. 47 """ 48 triu = np.triu_indices(feature.shape[0], 1) 49 upper_tri_pdists = np.linalg.norm(feature[triu[1]] - feature[triu[0]], axis=1) 50 if squared: 51 upper_tri_pdists **= 2. 52 num_data = feature.shape[0] 53 pairwise_distances = np.zeros((num_data, num_data)) 54 pairwise_distances[np.triu_indices(num_data, 1)] = upper_tri_pdists 55 # Make symmetrical. 56 pairwise_distances = pairwise_distances + pairwise_distances.T - np.diag( 57 pairwise_distances.diagonal()) 58 return pairwise_distances 59 60 61class ContrastiveLossTest(test.TestCase): 62 63 def testContrastive(self): 64 with self.test_session(): 65 num_data = 10 66 feat_dim = 6 67 margin = 1.0 68 69 embeddings_anchor = np.random.rand(num_data, feat_dim).astype(np.float32) 70 embeddings_positive = np.random.rand(num_data, feat_dim).astype( 71 np.float32) 72 labels = np.random.randint(0, 2, size=(num_data,)).astype(np.float32) 73 74 # Compute the loss in NP 75 dist = np.sqrt( 76 np.sum(np.square(embeddings_anchor - embeddings_positive), axis=1)) 77 loss_np = np.mean( 78 labels * np.square(dist) + 79 (1.0 - labels) * np.square(np.maximum(margin - dist, 0.0))) 80 # Compute the loss with TF 81 loss_tf = metric_loss_ops.contrastive_loss( 82 labels=ops.convert_to_tensor(labels), 83 embeddings_anchor=ops.convert_to_tensor(embeddings_anchor), 84 embeddings_positive=ops.convert_to_tensor(embeddings_positive), 85 margin=margin) 86 loss_tf = loss_tf.eval() 87 self.assertAllClose(loss_np, loss_tf) 88 89 90class TripletSemiHardLossTest(test.TestCase): 91 92 def testTripletSemiHard(self): 93 with self.test_session(): 94 num_data = 10 95 feat_dim = 6 96 margin = 1.0 97 num_classes = 4 98 99 embedding = np.random.rand(num_data, feat_dim).astype(np.float32) 100 labels = np.random.randint( 101 0, num_classes, size=(num_data)).astype(np.float32) 102 103 # Reshape labels to compute adjacency matrix. 104 labels_reshaped = np.reshape(labels, (labels.shape[0], 1)) 105 # Compute the loss in NP. 106 adjacency = np.equal(labels_reshaped, labels_reshaped.T) 107 108 pdist_matrix = pairwise_distance_np(embedding, squared=True) 109 loss_np = 0.0 110 num_positives = 0.0 111 for i in range(num_data): 112 for j in range(num_data): 113 if adjacency[i][j] > 0.0 and i != j: 114 num_positives += 1.0 115 116 pos_distance = pdist_matrix[i][j] 117 neg_distances = [] 118 119 for k in range(num_data): 120 if adjacency[i][k] == 0: 121 neg_distances.append(pdist_matrix[i][k]) 122 123 # Sort by distance. 124 neg_distances.sort() 125 chosen_neg_distance = neg_distances[0] 126 127 for l in range(len(neg_distances)): 128 chosen_neg_distance = neg_distances[l] 129 if chosen_neg_distance > pos_distance: 130 break 131 132 loss_np += np.maximum( 133 0.0, margin - chosen_neg_distance + pos_distance) 134 135 loss_np /= num_positives 136 137 # Compute the loss in TF. 138 loss_tf = metric_loss_ops.triplet_semihard_loss( 139 labels=ops.convert_to_tensor(labels), 140 embeddings=ops.convert_to_tensor(embedding), 141 margin=margin) 142 loss_tf = loss_tf.eval() 143 self.assertAllClose(loss_np, loss_tf) 144 145 146class LiftedStructLossTest(test.TestCase): 147 148 def testLiftedStruct(self): 149 with self.test_session(): 150 num_data = 10 151 feat_dim = 6 152 margin = 1.0 153 num_classes = 4 154 155 embedding = np.random.rand(num_data, feat_dim).astype(np.float32) 156 labels = np.random.randint( 157 0, num_classes, size=(num_data)).astype(np.float32) 158 # Reshape labels to compute adjacency matrix. 159 labels_reshaped = np.reshape(labels, (labels.shape[0], 1)) 160 161 # Compute the loss in NP 162 adjacency = np.equal(labels_reshaped, labels_reshaped.T) 163 pdist_matrix = pairwise_distance_np(embedding) 164 loss_np = 0.0 165 num_constraints = 0.0 166 for i in range(num_data): 167 for j in range(num_data): 168 if adjacency[i][j] > 0.0 and i != j: 169 d_pos = pdist_matrix[i][j] 170 negs = [] 171 for k in range(num_data): 172 if not adjacency[i][k]: 173 negs.append(margin - pdist_matrix[i][k]) 174 for l in range(num_data): 175 if not adjacency[j][l]: 176 negs.append(margin - pdist_matrix[j][l]) 177 178 negs = np.array(negs) 179 max_elem = np.max(negs) 180 negs -= max_elem 181 negs = np.exp(negs) 182 soft_maximum = np.log(np.sum(negs)) + max_elem 183 184 num_constraints += 1.0 185 this_loss = max(soft_maximum + d_pos, 0) 186 loss_np += this_loss * this_loss 187 188 loss_np = loss_np / num_constraints / 2.0 189 190 # Compute the loss in TF 191 loss_tf = metric_loss_ops.lifted_struct_loss( 192 labels=ops.convert_to_tensor(labels), 193 embeddings=ops.convert_to_tensor(embedding), 194 margin=margin) 195 loss_tf = loss_tf.eval() 196 self.assertAllClose(loss_np, loss_tf) 197 198 199def convert_to_list_of_sparse_tensor(np_matrix): 200 list_of_sparse_tensors = [] 201 nrows, ncols = np_matrix.shape 202 for i in range(nrows): 203 sp_indices = [] 204 for j in range(ncols): 205 if np_matrix[i][j] == 1: 206 sp_indices.append([j]) 207 208 num_non_zeros = len(sp_indices) 209 list_of_sparse_tensors.append(sparse_tensor.SparseTensor( 210 indices=np.array(sp_indices), 211 values=np.ones((num_non_zeros,)), 212 dense_shape=np.array([ncols,]))) 213 214 return list_of_sparse_tensors 215 216 217class NpairsLossTest(test.TestCase): 218 219 def testNpairs(self): 220 with self.test_session(): 221 num_data = 15 222 feat_dim = 6 223 num_classes = 5 224 reg_lambda = 0.02 225 226 embeddings_anchor = np.random.rand(num_data, feat_dim).astype(np.float32) 227 embeddings_positive = np.random.rand(num_data, feat_dim).astype( 228 np.float32) 229 230 labels = np.random.randint( 231 0, num_classes, size=(num_data)).astype(np.float32) 232 # Reshape labels to compute adjacency matrix. 233 labels_reshaped = np.reshape(labels, (labels.shape[0], 1)) 234 235 # Compute the loss in NP 236 reg_term = np.mean(np.sum(np.square(embeddings_anchor), 1)) 237 reg_term += np.mean(np.sum(np.square(embeddings_positive), 1)) 238 reg_term *= 0.25 * reg_lambda 239 240 similarity_matrix = np.matmul(embeddings_anchor, embeddings_positive.T) 241 242 labels_remapped = np.equal( 243 labels_reshaped, labels_reshaped.T).astype(np.float32) 244 labels_remapped /= np.sum(labels_remapped, axis=1, keepdims=True) 245 246 xent_loss = math_ops.reduce_mean(nn.softmax_cross_entropy_with_logits( 247 logits=ops.convert_to_tensor(similarity_matrix), 248 labels=ops.convert_to_tensor(labels_remapped))).eval() 249 loss_np = xent_loss + reg_term 250 251 # Compute the loss in TF 252 loss_tf = metric_loss_ops.npairs_loss( 253 labels=ops.convert_to_tensor(labels), 254 embeddings_anchor=ops.convert_to_tensor(embeddings_anchor), 255 embeddings_positive=ops.convert_to_tensor(embeddings_positive), 256 reg_lambda=reg_lambda) 257 loss_tf = loss_tf.eval() 258 self.assertAllClose(loss_np, loss_tf) 259 260 261class NpairsLossMultiLabelTest(test.TestCase): 262 263 def testNpairsMultiLabelLossWithSingleLabelEqualsNpairsLoss(self): 264 with self.test_session(): 265 num_data = 15 266 feat_dim = 6 267 reg_lambda = 0.02 268 269 embeddings_anchor = np.random.rand(num_data, feat_dim).astype(np.float32) 270 embeddings_positive = np.random.rand(num_data, feat_dim).astype( 271 np.float32) 272 labels = np.arange(num_data) 273 labels = np.reshape(labels, -1) 274 275 # Compute vanila npairs loss. 276 loss_npairs = metric_loss_ops.npairs_loss( 277 labels=ops.convert_to_tensor(labels), 278 embeddings_anchor=ops.convert_to_tensor(embeddings_anchor), 279 embeddings_positive=ops.convert_to_tensor(embeddings_positive), 280 reg_lambda=reg_lambda).eval() 281 282 # Compute npairs multilabel loss. 283 labels_one_hot = np.identity(num_data) 284 loss_npairs_multilabel = metric_loss_ops.npairs_loss_multilabel( 285 sparse_labels=convert_to_list_of_sparse_tensor(labels_one_hot), 286 embeddings_anchor=ops.convert_to_tensor(embeddings_anchor), 287 embeddings_positive=ops.convert_to_tensor(embeddings_positive), 288 reg_lambda=reg_lambda).eval() 289 290 self.assertAllClose(loss_npairs, loss_npairs_multilabel) 291 292 def testNpairsMultiLabel(self): 293 with self.test_session(): 294 num_data = 15 295 feat_dim = 6 296 num_classes = 10 297 reg_lambda = 0.02 298 299 embeddings_anchor = np.random.rand(num_data, feat_dim).astype(np.float32) 300 embeddings_positive = np.random.rand(num_data, feat_dim).astype( 301 np.float32) 302 303 labels = np.random.randint(0, 2, (num_data, num_classes)) 304 # set entire column to one so that each row has at least one bit set. 305 labels[:, -1] = 1 306 307 # Compute the loss in NP 308 reg_term = np.mean(np.sum(np.square(embeddings_anchor), 1)) 309 reg_term += np.mean(np.sum(np.square(embeddings_positive), 1)) 310 reg_term *= 0.25 * reg_lambda 311 312 similarity_matrix = np.matmul(embeddings_anchor, embeddings_positive.T) 313 314 labels_remapped = np.dot(labels, labels.T).astype(np.float) 315 labels_remapped /= np.sum(labels_remapped, 1, keepdims=True) 316 317 xent_loss = math_ops.reduce_mean(nn.softmax_cross_entropy_with_logits( 318 logits=ops.convert_to_tensor(similarity_matrix), 319 labels=ops.convert_to_tensor(labels_remapped))).eval() 320 loss_np = xent_loss + reg_term 321 322 # Compute the loss in TF 323 loss_tf = metric_loss_ops.npairs_loss_multilabel( 324 sparse_labels=convert_to_list_of_sparse_tensor(labels), 325 embeddings_anchor=ops.convert_to_tensor(embeddings_anchor), 326 embeddings_positive=ops.convert_to_tensor(embeddings_positive), 327 reg_lambda=reg_lambda) 328 loss_tf = loss_tf.eval() 329 330 self.assertAllClose(loss_np, loss_tf) 331 332 333def compute_ground_truth_cluster_score(feat, y): 334 y_unique = np.unique(y) 335 score_gt_np = 0.0 336 for c in y_unique: 337 feat_subset = feat[y == c, :] 338 pdist_subset = pairwise_distance_np(feat_subset) 339 score_gt_np += -1.0 * np.min(np.sum(pdist_subset, axis=0)) 340 score_gt_np = score_gt_np.astype(np.float32) 341 return score_gt_np 342 343 344def compute_cluster_loss_numpy(feat, 345 y, 346 margin_multiplier=1.0, 347 enable_pam_finetuning=True): 348 if enable_pam_finetuning: 349 facility = ForwardGreedyFacility( 350 n_clusters=np.unique(y).size).pam_augmented_fit(feat, y, 351 margin_multiplier) 352 else: 353 facility = ForwardGreedyFacility( 354 n_clusters=np.unique(y).size).loss_augmented_fit(feat, y, 355 margin_multiplier) 356 357 score_augmented = facility.score_aug_ 358 score_gt = compute_ground_truth_cluster_score(feat, y) 359 return np.maximum(np.float32(0.0), score_augmented - score_gt) 360 361 362class ForwardGreedyFacility(object): 363 364 def __init__(self, n_clusters=8): 365 self.n_clusters = n_clusters 366 self.center_ics_ = None 367 368 def _check_init_args(self): 369 # Check n_clusters. 370 if (self.n_clusters is None or self.n_clusters <= 0 or 371 not isinstance(self.n_clusters, int)): 372 raise ValueError('n_clusters has to be nonnegative integer.') 373 374 def loss_augmented_fit(self, feat, y, loss_mult): 375 """Fit K-Medoids to the provided data.""" 376 self._check_init_args() 377 # Check that the array is good and attempt to convert it to 378 # Numpy array if possible. 379 feat = self._check_array(feat) 380 # Apply distance metric to get the distance matrix. 381 pdists = pairwise_distance_np(feat) 382 383 num_data = feat.shape[0] 384 candidate_ids = list(range(num_data)) 385 candidate_scores = np.zeros(num_data,) 386 subset = [] 387 388 k = 0 389 while k < self.n_clusters: 390 candidate_scores = [] 391 for i in candidate_ids: 392 # push i to subset. 393 subset.append(i) 394 marginal_cost = -1.0 * np.sum(np.min(pdists[:, subset], axis=1)) 395 loss = 1.0 - metrics.normalized_mutual_info_score( 396 y, self._get_cluster_ics(pdists, subset)) 397 candidate_scores.append(marginal_cost + loss_mult * loss) 398 # remove i from subset. 399 subset.pop() 400 401 # push i_star to subset. 402 i_star = candidate_ids[np.argmax(candidate_scores)] 403 subset.append(i_star) 404 # remove i_star from candidate indices. 405 candidate_ids.remove(i_star) 406 k += 1 407 408 # Expose labels_ which are the assignments of 409 # the training data to clusters. 410 self.labels_ = self._get_cluster_ics(pdists, subset) 411 # Expose cluster centers, i.e. medoids. 412 self.cluster_centers_ = feat.take(subset, axis=0) 413 # Expose indices of chosen cluster centers. 414 self.center_ics_ = subset 415 # Expose the score = -\sum_{i \in V} min_{j \in S} || x_i - x_j || 416 self.score_ = np.float32(-1.0) * self._get_facility_distance(pdists, subset) 417 self.score_aug_ = self.score_ + loss_mult * ( 418 1.0 - metrics.normalized_mutual_info_score( 419 y, self._get_cluster_ics(pdists, subset))) 420 self.score_aug_ = self.score_aug_.astype(np.float32) 421 # Expose the chosen cluster indices. 422 self.subset_ = subset 423 return self 424 425 def _augmented_update_medoid_ics_in_place(self, pdists, y_gt, cluster_ics, 426 medoid_ics, loss_mult): 427 for cluster_idx in range(self.n_clusters): 428 # y_pred = self._get_cluster_ics(D, medoid_ics) 429 # Don't prematurely do the assignment step. 430 # Do this after we've updated all cluster medoids. 431 y_pred = cluster_ics 432 433 if sum(y_pred == cluster_idx) == 0: 434 # Cluster is empty. 435 continue 436 437 curr_score = ( 438 -1.0 * np.sum( 439 pdists[medoid_ics[cluster_idx], y_pred == cluster_idx]) + 440 loss_mult * (1.0 - metrics.normalized_mutual_info_score( 441 y_gt, y_pred))) 442 443 pdist_in = pdists[y_pred == cluster_idx, :] 444 pdist_in = pdist_in[:, y_pred == cluster_idx] 445 446 all_scores_fac = np.sum(-1.0 * pdist_in, axis=1) 447 all_scores_loss = [] 448 for i in range(y_pred.size): 449 if y_pred[i] != cluster_idx: 450 continue 451 # remove this cluster's current centroid 452 medoid_ics_i = medoid_ics[:cluster_idx] + medoid_ics[cluster_idx + 1:] 453 # add this new candidate to the centroid list 454 medoid_ics_i += [i] 455 y_pred_i = self._get_cluster_ics(pdists, medoid_ics_i) 456 all_scores_loss.append(loss_mult * ( 457 1.0 - metrics.normalized_mutual_info_score(y_gt, y_pred_i))) 458 459 all_scores = all_scores_fac + all_scores_loss 460 max_score_idx = np.argmax(all_scores) 461 max_score = all_scores[max_score_idx] 462 463 if max_score > curr_score: 464 medoid_ics[cluster_idx] = np.where( 465 y_pred == cluster_idx)[0][max_score_idx] 466 467 def pam_augmented_fit(self, feat, y, loss_mult): 468 pam_max_iter = 5 469 self._check_init_args() 470 feat = self._check_array(feat) 471 pdists = pairwise_distance_np(feat) 472 self.loss_augmented_fit(feat, y, loss_mult) 473 print('PAM -1 (before PAM): score: %f, score_aug: %f' % ( 474 self.score_, self.score_aug_)) 475 # Initialize from loss augmented facility location 476 subset = self.center_ics_ 477 for iter_ in range(pam_max_iter): 478 # update the cluster assignment 479 cluster_ics = self._get_cluster_ics(pdists, subset) 480 # update the medoid for each clusters 481 self._augmented_update_medoid_ics_in_place(pdists, y, cluster_ics, subset, 482 loss_mult) 483 self.score_ = np.float32(-1.0) * self._get_facility_distance( 484 pdists, subset) 485 self.score_aug_ = self.score_ + loss_mult * ( 486 1.0 - metrics.normalized_mutual_info_score( 487 y, self._get_cluster_ics(pdists, subset))) 488 self.score_aug_ = self.score_aug_.astype(np.float32) 489 print('PAM iter: %d, score: %f, score_aug: %f' % (iter_, self.score_, 490 self.score_aug_)) 491 492 self.center_ics_ = subset 493 self.labels_ = cluster_ics 494 return self 495 496 def _check_array(self, feat): 497 # Check that the number of clusters is less than or equal to 498 # the number of samples 499 if self.n_clusters > feat.shape[0]: 500 raise ValueError('The number of medoids ' + '({}) '.format( 501 self.n_clusters) + 'must be larger than the number ' + 502 'of samples ({})'.format(feat.shape[0])) 503 return feat 504 505 def _get_cluster_ics(self, pdists, subset): 506 """Returns cluster indices for pdist and current medoid indices.""" 507 # Assign data points to clusters based on 508 # which cluster assignment yields 509 # the smallest distance` 510 cluster_ics = np.argmin(pdists[subset, :], axis=0) 511 return cluster_ics 512 513 def _get_facility_distance(self, pdists, subset): 514 return np.sum(np.min(pdists[subset, :], axis=0)) 515 516 517class ClusterLossTest(test.TestCase): 518 519 def _genClusters(self, n_samples, n_clusters): 520 blobs = datasets.make_blobs( 521 n_samples=n_samples, centers=n_clusters) 522 embedding, labels = blobs 523 embedding = (embedding - embedding.mean(axis=0)) / embedding.std(axis=0) 524 embedding = embedding.astype(np.float32) 525 return embedding, labels 526 527 def testClusteringLossPAMOff(self): 528 if not HAS_SKLEARN: 529 return 530 with self.test_session(): 531 margin_multiplier = 10.0 532 embeddings, labels = self._genClusters(n_samples=128, n_clusters=64) 533 534 loss_np = compute_cluster_loss_numpy( 535 embeddings, labels, margin_multiplier, enable_pam_finetuning=False) 536 loss_tf = metric_loss_ops.cluster_loss( 537 labels=ops.convert_to_tensor(labels), 538 embeddings=ops.convert_to_tensor(embeddings), 539 margin_multiplier=margin_multiplier, 540 enable_pam_finetuning=False) 541 loss_tf = loss_tf.eval() 542 self.assertAllClose(loss_np, loss_tf) 543 544 def testClusteringLossPAMOn(self): 545 if not HAS_SKLEARN: 546 return 547 with self.test_session(): 548 margin_multiplier = 10.0 549 embeddings, labels = self._genClusters(n_samples=128, n_clusters=64) 550 551 loss_np = compute_cluster_loss_numpy( 552 embeddings, labels, margin_multiplier, enable_pam_finetuning=True) 553 loss_tf = metric_loss_ops.cluster_loss( 554 labels=ops.convert_to_tensor(labels), 555 embeddings=ops.convert_to_tensor(embeddings), 556 margin_multiplier=margin_multiplier, 557 enable_pam_finetuning=True) 558 loss_tf = loss_tf.eval() 559 self.assertAllClose(loss_np, loss_tf) 560 561if __name__ == '__main__': 562 test.main() 563