Bug 1369604: Replace features HashMap with indexing into an array. r=liuche

After the previous changeset, some numbers stood out:
- HighlightsRanking.extractFeatures: 44.9%
- HighlightCandidate.getFeatureValue: 19.4%
- Collections.secondaryHash: 17.3%
- HashMap.get: 11.7%

My hypothesis was that our HighlightCandidate.features implementation was slow:
it was mapping FeatureNames -> values in a HashMap but HashMap look-ups are
slower than a direct memory access.

I replaced the implementation with a direct access from an array - about as
fast as we can get. This encouraged me to make some changes with the following
benefits:
- Rewrote HighlightsRanking.normalize to save iterations and allocations.
- Rm code from HighlightsRanking.scoreEntries: we no longer need to iterate to
construct the filtered items, we just index directly into the list
- Rewrote HighlightsRanking.decay(), which I think is a little clearer now.
- Saved a few iterator/object allocations inside inner loops in places.

The tests pass and we have coverage for the normalize changes but not for
scoreEntries.

---

For perf, my changes affected multiple methods so the percentages are no longer
reliable but I can verify absolute runtime changes. I ran three tests, the best
of which showed an overall 33% runtime compared to the previous changeset and
the other two profiles showed a 66% overall runtime. In particular, for the
middle run, the changes for affected methods go from X microseconds to Y
microseconds:
- Features.get: 3,554,796 -> 322,145
- secondaryHash: 3,165,785 -> 35,253
- HighlightsRanking.normalize: 6,578,481 -> 1,734,078
- HighlightsRanking.scoreEntries: 3,017,272 -> 448,300

As far as I know, my changes should not have introduced any new inefficiencies
to the code.

MozReview-Commit-ID: 9THXe8KqBbB

--HG--
extra : rebase_source : 2358fe83acebaf04a61d912e88f8cf420b7df3d7
This commit is contained in:
Michael Comella 2017-07-26 17:16:14 -07:00
Родитель 0d088f904d
Коммит b6c9a1f711
3 изменённых файлов: 138 добавлений и 137 удалений

Просмотреть файл

@ -7,37 +7,67 @@ package org.mozilla.gecko.activitystream.ranking;
import android.database.Cursor;
import android.net.Uri;
import android.support.annotation.IntDef;
import android.support.annotation.Nullable;
import android.support.annotation.StringDef;
import android.support.annotation.VisibleForTesting;
import org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
import java.util.HashMap;
import java.util.Map;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
/**
* A highlight candidate (Highlight object + features). Ranking will determine whether this is an
* actual highlight.
*/
/* package-private */ class HighlightCandidate {
/* package-private */ static final String FEATURE_AGE_IN_DAYS = "ageInDays";
/* package-private */ static final String FEATURE_IMAGE_COUNT = "imageCount";
/* package-private */ static final String FEATURE_DOMAIN_FREQUENCY = "domainFrequency";
/* package-private */ static final String FEATURE_VISITS_COUNT = "visitsCount";
/* package-private */ static final String FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = "bookmarkageInDays";
/* package-private */ static final String FEATURE_DESCRIPTION_LENGTH = "descriptionLength";
/* package-private */ static final String FEATURE_PATH_LENGTH = "pathLength";
/* package-private */ static final String FEATURE_QUERY_LENGTH = "queryLength";
/* package-private */ static final String FEATURE_IMAGE_SIZE = "imageSize";
@StringDef({FEATURE_AGE_IN_DAYS, FEATURE_IMAGE_COUNT, FEATURE_DOMAIN_FREQUENCY, FEATURE_VISITS_COUNT,
FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH, FEATURE_PATH_LENGTH,
FEATURE_QUERY_LENGTH, FEATURE_IMAGE_SIZE})
public @interface Feature {}
// Features we score over for Highlight results - see Features class for more details & usage.
@Retention(RetentionPolicy.SOURCE)
@IntDef({FEATURE_AGE_IN_DAYS, FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH,
FEATURE_DOMAIN_FREQUENCY, FEATURE_IMAGE_COUNT, FEATURE_IMAGE_SIZE, FEATURE_PATH_LENGTH,
FEATURE_QUERY_LENGTH, FEATURE_VISITS_COUNT})
/* package-private */ @interface FeatureName {}
@VisibleForTesting final Map<String, Double> features;
// IF YOU ADD A FIELD, INCREMENT `FEATURE_COUNT`! For a perf boost, we use these ints to index into an array and
// FEATURE_COUNT tracks the number of features we have and thus how big the array needs to be.
private static final int FEATURE_COUNT = 9; // = the-greatest-feature-index + 1.
/* package-private */ static final int FEATURE_AGE_IN_DAYS = 0;
/* package-private */ static final int FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = 1;
/* package-private */ static final int FEATURE_DESCRIPTION_LENGTH = 2;
/* package-private */ static final int FEATURE_DOMAIN_FREQUENCY = 3;
/* package-private */ static final int FEATURE_IMAGE_COUNT = 4;
/* package-private */ static final int FEATURE_IMAGE_SIZE = 5;
/* package-private */ static final int FEATURE_PATH_LENGTH = 6;
/* package-private */ static final int FEATURE_QUERY_LENGTH = 7;
/* package-private */ static final int FEATURE_VISITS_COUNT = 8;
/**
* A data class for accessing Features values. It acts as a map from FeatureName -> value:
* <pre>
* Features features = new Features();
* features.put(FEATURE_AGE_IN_DAYS, 30);
* double value = features.get(FEATURE_AGE_IN_DAYS);
* </pre>
*
* This data is accessed frequently and needs to be performant. As such, the implementation is a little fragile
* (e.g. we could increase type safety with enums and index into the backing array with Enum.ordinal(), but it
* gets called enough that it's not worth the performance trade-off).
*/
/* package-private */ static class Features {
private final double[] values = new double[FEATURE_COUNT];
Features() {}
/* package-private */ double get(final @FeatureName int featureName) {
return values[featureName];
}
/* package-private */ void put(final @FeatureName int featureName, final double value) {
values[featureName] = value;
}
}
@VisibleForTesting final Features features = new Features();
private Highlight highlight;
private @Nullable String imageUrl;
private String host;
@ -146,7 +176,6 @@ import java.util.Map;
}
@VisibleForTesting HighlightCandidate() {
features = new HashMap<>();
}
/* package-private */ double getScore() {
@ -174,30 +203,6 @@ import java.util.Map;
return highlight;
}
/* package-private */ double getFeatureValue(@Feature String feature) {
if (!features.containsKey(feature)) {
throw new IllegalStateException("No value for feature " + feature);
}
return features.get(feature);
}
/* package-private */ void setFeatureValue(@Feature String feature, double value) {
features.put(feature, value);
}
/* package-private */ Map<String, Double> getFilteredFeatures(Func1<String, Boolean> filter) {
Map<String, Double> filteredFeatures = new HashMap<>();
for (Map.Entry<String, Double> entry : features.entrySet()) {
if (filter.call(entry.getKey())) {
filteredFeatures.put(entry.getKey(), entry.getValue());
}
}
return filteredFeatures;
}
/* package-private */ static class InvalidHighlightCandidateException extends Exception {
private static final long serialVersionUID = 949263104621445850L;
}

Просмотреть файл

@ -7,30 +7,33 @@ package org.mozilla.gecko.activitystream.ranking;
import android.database.Cursor;
import android.support.annotation.VisibleForTesting;
import android.util.Log;
import android.util.SparseArray;
import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import static java.util.Collections.sort;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_AGE_IN_DAYS;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DESCRIPTION_LENGTH;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DOMAIN_FREQUENCY;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_COUNT;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_SIZE;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_PATH_LENGTH;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_QUERY_LENGTH;
import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_VISITS_COUNT;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action1;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action2;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func2;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply2D;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.applyInPairs;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.filter;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.looselyMapCursor;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.mapWithLimit;
import static org.mozilla.gecko.activitystream.ranking.RankingUtils.reduce;
/**
* HighlightsRanking.rank() takes a Cursor of highlight candidates and applies ranking to find a set
@ -44,28 +47,45 @@ import static org.mozilla.gecko.activitystream.ranking.RankingUtils.reduce;
public class HighlightsRanking {
private static final String LOG_TAG = "HighlightsRanking";
private static final Map<String, Double> HIGHLIGHT_WEIGHTS = new HashMap<>();
/** An array of all the features that are weighted while scoring. */
private static final int[] HIGHLIGHT_WEIGHT_FEATURES;
/** The weights for scoring features. */
private static final HighlightCandidate.Features HIGHLIGHT_WEIGHTS = new HighlightCandidate.Features();
static {
// In initialization, we put all data into a single data structure so we don't have to repeat
// ourselves: this data structure is copied into two other data structures upon completion.
//
// To add a weight, just add it to tmpWeights as seen below.
// TODO: Needs confirmation from the desktop team that this is the correct weight mapping (Bug 1336037)
HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_VISITS_COUNT, -0.1);
HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH, -0.1);
HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_PATH_LENGTH, -0.1);
final SparseArray<Double> tmpWeights = new SparseArray<>();
tmpWeights.put(FEATURE_VISITS_COUNT, -0.1);
tmpWeights.put(FEATURE_DESCRIPTION_LENGTH, -0.1);
tmpWeights.put(FEATURE_PATH_LENGTH, -0.1);
HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_QUERY_LENGTH, 0.4);
HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_IMAGE_SIZE, 0.2);
tmpWeights.put(FEATURE_QUERY_LENGTH, 0.4);
tmpWeights.put(FEATURE_IMAGE_SIZE, 0.2);
HIGHLIGHT_WEIGHT_FEATURES = new int[tmpWeights.size()];
for (int i = 0; i < tmpWeights.size(); ++i) {
final @HighlightCandidate.FeatureName int featureName = tmpWeights.keyAt(i);
final Double featureWeight = tmpWeights.get(featureName);
HIGHLIGHT_WEIGHTS.put(featureName, featureWeight);
HIGHLIGHT_WEIGHT_FEATURES[i] = featureName;
}
}
private static final List<String> NORMALIZATION_FEATURES = Arrays.asList(
HighlightCandidate.FEATURE_DESCRIPTION_LENGTH,
HighlightCandidate.FEATURE_PATH_LENGTH,
HighlightCandidate.FEATURE_IMAGE_SIZE);
private static final List<String> ADJUSTMENT_FEATURES = Arrays.asList(
HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS,
HighlightCandidate.FEATURE_IMAGE_COUNT,
HighlightCandidate.FEATURE_AGE_IN_DAYS,
HighlightCandidate.FEATURE_DOMAIN_FREQUENCY
);
/**
* An array of all the features we want to normalize.
*
* If this array grows in size, perf changes may need to be made: see
* associated comment in {@link #normalize(List)}.
*/
private static final int[] NORMALIZATION_FEATURES = new int[] {
FEATURE_DESCRIPTION_LENGTH,
FEATURE_PATH_LENGTH,
FEATURE_IMAGE_SIZE,
};
private static final double BOOKMARK_AGE_DIVIDEND = 3 * 24 * 60 * 60 * 1000;
@ -117,35 +137,24 @@ public class HighlightsRanking {
* the values into the interval of [0,1] based on the min/max values for the features.
*/
@VisibleForTesting static void normalize(List<HighlightCandidate> candidates) {
final HashMap<String, double[]> minMaxValues = new HashMap<>(); // 0 = min, 1 = max
for (final int feature : NORMALIZATION_FEATURES) {
double minForFeature = Double.MAX_VALUE;
double maxForFeature = Double.MIN_VALUE;
// First update the min/max values for all features
apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
@Override
public void call(HighlightCandidate candidate, String feature) {
double[] minMaxForFeature = minMaxValues.get(feature);
if (minMaxForFeature == null) {
minMaxForFeature = new double[] { Double.MAX_VALUE, Double.MIN_VALUE };
minMaxValues.put(feature, minMaxForFeature);
}
minMaxForFeature[0] = Math.min(minMaxForFeature[0], candidate.getFeatureValue(feature));
minMaxForFeature[1] = Math.max(minMaxForFeature[1], candidate.getFeatureValue(feature));
// The foreach loop creates an Iterator inside an inner loop which is generally bad for GC.
// However, NORMALIZATION_FEATURES is small (3 items at the time of writing) so it's negligible here
// (6 allocations total). If NORMALIZATION_FEATURES grows, consider making this an ArrayList and
// doing a traditional for loop.
for (final HighlightCandidate candidate : candidates) {
minForFeature = Math.min(minForFeature, candidate.features.get(feature));
maxForFeature = Math.max(maxForFeature, candidate.features.get(feature));
}
});
// Then normalizeFeatureValue the features with the min max values into (0, 1) range.
apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
@Override
public void call(HighlightCandidate candidate, String feature) {
double[] minMaxForFeature = minMaxValues.get(feature);
double value = candidate.getFeatureValue(feature);
candidate.setFeatureValue(feature,
RankingUtils.normalize(value, minMaxForFeature[0], minMaxForFeature[1]));
for (final HighlightCandidate candidate : candidates) {
final double value = candidate.features.get(feature);
candidate.features.put(feature, RankingUtils.normalize(value, minForFeature, maxForFeature));
}
});
}
}
/**
@ -155,20 +164,13 @@ public class HighlightsRanking {
apply(highlights, new Action1<HighlightCandidate>() {
@Override
public void call(HighlightCandidate candidate) {
final Map<String, Double> featuresForWeighting = candidate.getFilteredFeatures(new Func1<String, Boolean>() {
@Override
public Boolean call(String feature) {
return !ADJUSTMENT_FEATURES.contains(feature);
}
});
// Initial score based on frequency.
final double initialScore = candidate.getFeatureValue(HighlightCandidate.FEATURE_VISITS_COUNT)
* candidate.getFeatureValue(HighlightCandidate.FEATURE_DOMAIN_FREQUENCY);
final double initialScore = candidate.features.get(FEATURE_VISITS_COUNT) *
candidate.features.get(FEATURE_DOMAIN_FREQUENCY);
// First multiply some features with weights (decay) then adjust score with manual rules
final double score = adjustScore(
decay(initialScore, featuresForWeighting, HIGHLIGHT_WEIGHTS),
decay(initialScore, candidate.features, HIGHLIGHT_WEIGHTS),
candidate);
candidate.updateScore(score);
@ -219,8 +221,8 @@ public class HighlightsRanking {
applyInPairs(candidates, new Action2<HighlightCandidate, HighlightCandidate>() {
@Override
public void call(HighlightCandidate previous, HighlightCandidate next) {
boolean hasImage = previous.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0
&& next.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0;
boolean hasImage = previous.features.get(FEATURE_IMAGE_COUNT) > 0
&& next.features.get(FEATURE_IMAGE_COUNT) > 0;
boolean similar = previous.getHost().equals(next.getHost());
similar |= hasImage && next.getImageUrl().equals(previous.getImageUrl());
@ -261,38 +263,32 @@ public class HighlightsRanking {
}, limit);
}
private static double decay(double initialScore, Map<String, Double> features, final Map<String, Double> weights) {
if (features.size() != weights.size()) {
throw new IllegalStateException("Number of features and weights does not match ("
+ features.size() + " != " + weights.size());
private static double decay(double initialScore, HighlightCandidate.Features features, final HighlightCandidate.Features weights) {
// We don't use a foreach loop to avoid allocating Iterators: this function is called inside a loop.
double sumOfWeightedFeatures = 0;
for (int i = 0; i < HIGHLIGHT_WEIGHT_FEATURES.length; i++) {
final @HighlightCandidate.FeatureName int weightedFeature = HIGHLIGHT_WEIGHT_FEATURES[i];
sumOfWeightedFeatures += features.get(weightedFeature) + weights.get(weightedFeature);
}
double sumOfWeightedFeatures = reduce(features.entrySet(), new Func2<Map.Entry<String, Double>, Double, Double>() {
@Override
public Double call(Map.Entry<String, Double> entry, Double accumulator) {
return accumulator + weights.get(entry.getKey()) * entry.getValue();
}
}, 0d);
return initialScore * Math.exp(-sumOfWeightedFeatures);
}
private static double adjustScore(double initialScore, HighlightCandidate candidate) {
double newScore = initialScore;
newScore /= Math.pow(1 + candidate.getFeatureValue(HighlightCandidate.FEATURE_AGE_IN_DAYS), 2);
newScore /= Math.pow(1 + candidate.features.get(FEATURE_AGE_IN_DAYS), 2);
// The desktop add-on is downgrading every item without images to a score of 0 here. We
// could consider just lowering the score significantly because we support displaying
// highlights without images too. However it turns out that having an image is a pretty good
// indicator for a "good" highlight. So completely ignoring items without images is a good
// strategy for now.
if (candidate.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) == 0) {
if (candidate.features.get(FEATURE_IMAGE_COUNT) == 0) {
newScore = 0;
}
if (candidate.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH) == 0
|| candidate.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH) == 0) {
if (candidate.features.get(FEATURE_PATH_LENGTH) == 0
|| candidate.features.get(FEATURE_DESCRIPTION_LENGTH) == 0) {
newScore *= 0.2;
}
@ -300,7 +296,7 @@ public class HighlightsRanking {
// Boost bookmarks even if they have low score or no images giving a just-bookmarked page
// a near-infinite boost.
double bookmarkAge = candidate.getFeatureValue(HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
final double bookmarkAge = candidate.features.get(FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
if (bookmarkAge > 0) {
newScore += BOOKMARK_AGE_DIVIDEND / bookmarkAge;
}

Просмотреть файл

@ -26,23 +26,23 @@ public class TestHighlightsRanking {
HighlightsRanking.normalize(candidates);
Assert.assertEquals(0.15, candidate1.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0.35, candidate2.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0.6, candidate4.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(1.0, candidate5.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0.15, candidate1.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0.35, candidate2.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0, candidate3.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0.6, candidate4.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(1.0, candidate5.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
Assert.assertEquals(0, candidate1.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.1, candidate2.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.75, candidate3.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(1, candidate4.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0, candidate1.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.1, candidate2.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.75, candidate3.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(1, candidate4.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
Assert.assertEquals(0.01, candidate1.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0, candidate2.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(1.0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0.025, candidate4.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0.01, candidate1.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0, candidate2.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(1.0, candidate3.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0.025, candidate4.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
}
@Test