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