/*
* Copyright 2008-2013, ETH Zürich, Samuel Welten, Michael Kuhn, Tobias Langner,
* Sandro Affentranger, Lukas Bossard, Michael Grob, Rahul Jain,
* Dominic Langenegger, Sonia Mayor Alonso, Roger Odermatt, Tobias Schlueter,
* Yannick Stucki, Sebastian Wendland, Samuel Zehnder, Samuel Zihlmann,
* Samuel Zweifel
*
* This file is part of Jukefox.
*
* Jukefox is free software: you can redistribute it and/or modify it under the
* terms of the GNU General Public License as published by the Free Software
* Foundation, either version 3 of the License, or any later version. Jukefox is
* distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
* without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* Jukefox. If not, see <http://www.gnu.org/licenses/>.
*/
package ch.ethz.dcg.jukefox.model.rating;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.Vector;
import ch.ethz.dcg.jukefox.commons.DataUnavailableException;
import ch.ethz.dcg.jukefox.commons.DataWriteException;
import ch.ethz.dcg.jukefox.commons.utils.Log;
import ch.ethz.dcg.jukefox.commons.utils.Utils;
import ch.ethz.dcg.jukefox.commons.utils.kdtree.KdTreePoint;
import ch.ethz.dcg.jukefox.data.db.IDbDataPortal;
import ch.ethz.dcg.jukefox.data.log.LogManager;
import ch.ethz.dcg.jukefox.data.log.RatingLogEntry;
import ch.ethz.dcg.jukefox.model.collection.BaseAlbum;
import ch.ethz.dcg.jukefox.model.collection.BaseArtist;
import ch.ethz.dcg.jukefox.model.collection.BaseSong;
import ch.ethz.dcg.jukefox.model.collection.SongCoords;
import ch.ethz.dcg.jukefox.model.collection.statistics.CollectionProperties;
import ch.ethz.dcg.jukefox.model.providers.OtherDataProvider;
import ch.ethz.dcg.jukefox.model.providers.SongProvider;
import ch.ethz.dcg.jukefox.model.rating.RatingEntry.RatingSource;
/**
* Helper class for writing rating entries into the database.
*/
public class RatingHelper {
/**
* How many songs the neighborhood should contain in average.
*/
private final static int AVG_NEIGHBORHOOD_SIZE = 10;
/**
* How many songs the neighborhood should contain at most.
*/
private final static int MAX_NEIGHBORHOOD_SIZE = 25;
/**
* Minimum weight so that we consider it as interresting. Weights below this value can be dropped since they most
* likely get outvoted anyway.
*/
public static final double MIN_WEIGHT = 0.01d;
private final static String TAG = RatingHelper.class.getName();
private final IDbDataPortal dbDataPortal;
private final SongProvider songProvider;
private final LogManager logManager;
private final int profileId;
private OtherDataProvider otherDataProvider;
public RatingHelper(int profileId, IDbDataPortal dbDataPortal, SongProvider songProvider,
OtherDataProvider otherDataProvider, LogManager logManager) {
this.profileId = profileId;
this.dbDataPortal = dbDataPortal;
this.songProvider = songProvider;
this.otherDataProvider = otherDataProvider;
this.logManager = logManager;
}
/**
* Adds rating entries for the played song and its neighbors out of the fraction which was played. This is done
* synchroneous.
*
* @param song
* The song
* @param playlogTimestamp
* The timestamp of the according playlog entry
* @param fractionPlayed
* How much was played (must be in [0, 1])
*
* @see #getRatingFromFractionPlayed(double)
*/
public void addRatingFromPlayLog(final BaseSong<BaseArtist, BaseAlbum> song, final Date playlogTimestamp,
final double fractionPlayed) {
// Calculate the rating
double rating = getRatingFromFractionPlayed(fractionPlayed);
// Write the rating for the main song
try {
dbDataPortal.getStatisticsHelper().writeRatingEntry(profileId, song.getId(), playlogTimestamp,
rating, 1.0d, RatingSource.Playlog);
} catch (DataWriteException e) {
Log.w(TAG, e);
}
try {
int neighborhoodSize = 0;
// Get song coordinates
final SongCoords mainCoords = getCoordinatesForSong(song);
if ((mainCoords != null) && (mainCoords.getCoords() != null)) {
// Only rate neighborhood for songs with coordinates
// Calculate the maximum distance
double maxDistance = getMaxDistance();
// Find neighbors
Vector<KdTreePoint<Integer>> neighbors = songProvider.getSongsAroundPositionEuclidian(
mainCoords.getCoords(), (float) maxDistance);
neighborhoodSize = neighbors.size();
// Sort the neighbors, so that the nearest ones come first
Collections.sort(neighbors, new Comparator<KdTreePoint<Integer>>() {
@Override
public int compare(KdTreePoint<Integer> p1, KdTreePoint<Integer> p2) {
float distP1 = Utils.distance(mainCoords.getCoords(), p1.getPosition());
float distP2 = Utils.distance(mainCoords.getCoords(), p2.getPosition());
return Float.compare(distP1, distP2);
}
});
// Write the weighted rating for the neighbors
int i = 0;
for (KdTreePoint<Integer> node : neighbors) {
try {
// Find the songId
int songId = node.getID();
if (songId == song.getId()) {
// Don't reprocess it
continue;
}
// Limit the neighborhood by a fixed size
if (i >= MAX_NEIGHBORHOOD_SIZE) {
break;
}
i++;
// Find the coordinates for this song
float[] coords = node.getPosition();
// Calculate the weight for this distance
double weight = getWeightForNeighbor(mainCoords.getCoords(), coords, maxDistance);
// Limit the neighborhood by a minimum weight
if (weight < MIN_WEIGHT) {
break;
}
// Write the rating for this neighbor
dbDataPortal.getStatisticsHelper().writeRatingEntry(profileId, songId,
playlogTimestamp,
rating, weight, RatingSource.Neighbor);
} catch (DataWriteException e2) {
// Just ignore it
Log.w(TAG, e2);
}
}
}
// Write log for this rating round (only if not in transaction, since otherwise we are most probably in the fake round of NextSongCalculationThread)
if (!dbDataPortal.inTransaction()) {
// Get the meSongId of the played song
Integer meSongId = null;
try {
meSongId = otherDataProvider.getMusicExplorerSongId(song);
} catch (DataUnavailableException e) {
Log.w(TAG, e);
}
RatingLogEntry.Builder log = RatingLogEntry.createInstance()
.setPlayedSong((meSongId != null) ? meSongId : 0)
.setRating((float) rating)
.setNeighborhoodSize(neighborhoodSize);
logManager.addLogEntry(log.build());
}
} catch (DataUnavailableException e) {
// Some data is not available -> abort
Log.w(TAG, e);
return;
}
}
/**
* Returns the rating for the given play fraction. <br/>
* <p>
* Rating function: (x is percent of song which was played)
* <table>
* <tr>
* <td></td>
* <td>( -1</td>
* <td>, if x in [0, 0.25)</td>
* </tr>
* <tr>
* <td>f(x) =</td>
* <td>{ 4x - 2</td>
* <td>, if x in [0.25, 0.75) // Linear growth between 1/4 & 3/4</td>
* </tr>
* <tr>
* <td></td>
* <td>( 1</td>
* <td>, if x in [0.75, 1]</td>
* </tr>
* </table>
* </p>
*
* @param fractionPlayed
* How much was played (must be in [0, 1])
* @return The rating
*/
public static double getRatingFromFractionPlayed(double fractionPlayed) {
return Math.min(Math.max(4 * fractionPlayed - 2, -1), 1);
}
/**
* Returns the coordinates of the given song or null if it has none.
*
* @param song
* @return The coordinates or null
*/
private SongCoords getCoordinatesForSong(BaseSong<BaseArtist, BaseAlbum> song) {
try {
return dbDataPortal.getSongCoordsById(song.getId());
} catch (DataUnavailableException e) {
return null;
}
}
/**
* Returns the radius of the neighborhood.<br/>
* This is calculated so that in average {@link #AVG_NEIGHBORHOOD_SIZE} ({@value #AVG_NEIGHBORHOOD_SIZE}) songs are
* rated.<br/>
* We use the standard deviation to find the maximal distance to ensure the above property. We have:
* <p>
* proportion_around_mean = 1 - 2*NEIGHBORHOOD_SIZE_FRACTION = error_function(z / sqrt(2))
* </p>
* and therefore
* <p>
* z = error_function<sup>-1</sup>(1 - 2*NEIGHBORHOOD_SIZE_FRACTION) * sqrt(2)
* </p>
* The distance is then calculated using
* <p>
* max_distance = mean - std_deviation * z
* </p>
*
* @return The maximum distance
* @throws DataUnavailableException
* If the std deviation is not available
*/
private double getMaxDistance() throws DataUnavailableException {
CollectionProperties cp = otherDataProvider.getCollectionProperties();
double stdDeviation = cp.getSongDistanceStdDeviation();
double proportion = 1 - 2 * (AVG_NEIGHBORHOOD_SIZE / (double) otherDataProvider
.getNumberOfSongsWithCoordinates());
double z = invErrorFunction(proportion) * Math.sqrt(2);
double mean = cp.getAverageSongDistance();
return Math.max(mean - z * stdDeviation, 0); // Ensure that we get a positive distance
}
/**
* Returns an approximation of the inverse error-function for the given x.
*
* @param x
* @return erf<sup>-1</sup>(x)
* @see {@linkplain http://en.wikipedia.org/wiki/Error_function#Approximation_with_elementary_functions}
*/
private double invErrorFunction(double x) {
double a = 0.147;
double f1 = Math.pow(2 / (Math.PI * a) + Math.log(1 - x * x) / 2, 2);
double f2 = Math.log(1 - x * x) / a;
double f3 = 2 / (Math.PI * a) + Math.log(1 - x * x) / 2;
return Math.signum(x) * Math.sqrt(Math.sqrt(f1 - f2) - f3);
}
/**
* Returns the weight according to the distance between the given songs. The weight function <i>w</i> is linear and
* w(maxDistance) = 0, w(0) = 1.
*
* @param coords1
* @param coords2
* @return The calculated weight
*/
private double getWeightForNeighbor(float[] coords1, float[] coords2, double maxDistance) {
// Calculate the distance
double distance = Utils.distance(coords1, coords2);
// Return the weight
return -1 / maxDistance * distance + 1;
}
}