/* * 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.playmode.smartshuffle; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import ch.ethz.dcg.jukefox.commons.DataUnavailableException; import ch.ethz.dcg.jukefox.commons.utils.JoinableThread; import ch.ethz.dcg.jukefox.commons.utils.Log; import ch.ethz.dcg.jukefox.commons.utils.RandomProvider; import ch.ethz.dcg.jukefox.commons.utils.StopWatch; import ch.ethz.dcg.jukefox.commons.utils.TimingLogger; import ch.ethz.dcg.jukefox.data.db.IDbDataPortal; import ch.ethz.dcg.jukefox.data.log.LogManager; import ch.ethz.dcg.jukefox.data.log.NextSongCalculationLogEntry; import ch.ethz.dcg.jukefox.data.log.NextSongCalculationLogEntry.AgentsTiming; 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.PlaylistSong; import ch.ethz.dcg.jukefox.model.collection.PlaylistSong.SongSource; import ch.ethz.dcg.jukefox.model.player.playlog.PlayLog; import ch.ethz.dcg.jukefox.model.providers.OtherDataProvider; import ch.ethz.dcg.jukefox.model.providers.StatisticsProvider; import ch.ethz.dcg.jukefox.playmode.smartshuffle.agents.AgentManager; import ch.ethz.dcg.jukefox.playmode.smartshuffle.agents.IAgent; /** * This class searches for the next song which should be played. It asks several agents to return their proposals and * lets them rate all proposals. These ratings get weighted by the importance of the agents and a weighted drawing of * the proposed songs is done.<br/> * To reach regions in the music space which are not explored yet or we "forgot about", random entries from the set of * long not rated songs get added to the proposal list. <br/> * <br/> * There are two calculation modes: One which assumes, that the current song gets a positive and one that it gets a * negative rating. This is important, since we are calculating the next song asynchronous while the current song is * still playing. */ public abstract class NextSongCalculationThread extends JoinableThread { public enum Case { Positive, Negative }; /** * How many songs should be chosen at random from the list of long not rated songs and added to the song proposals * (Set to zero to disable this feature). */ private static final int LONG_NOT_RATED_SONG_COUNT = 1; /** * From when on a song is considered as long not played. [in h] */ private static final int LONG_NOT_RATED_THRESHOLD = 24 * 200; // 200d /** * The vote for long not played songs. */ private static final double LONG_NOT_RATED_SONG_VOTE = 0.2d; // Do not define TAG, use getTag() /** * How many songs are allowed to be proposed per agent. */ private static final int SONG_PROPOSAL_COUNT = 30; /** * How many of the proposed songs of an agent actually get used. */ private static final int SONG_PROPOSAL_USAGE_COUNT = 7; private final AgentManager agentManager; private final IDbDataPortal dbDataPortal; private final PlayLog playLog; private final LogManager logManager; private final StatisticsProvider statisticsProvider; private final OtherDataProvider otherDataProvider; private boolean abortCalculation; private final BaseSong<BaseArtist, BaseAlbum> currentSong; private List<BaseSong<BaseArtist, BaseAlbum>> proposedSongs = null; // List of song proposals (randomized by vote) private Map<BaseSong<BaseArtist, BaseAlbum>, Double> songVotes = null; // map containing the overall vote for a song private Map<BaseSong<BaseArtist, BaseAlbum>, Map<IAgent, Float>> agentVotesBySong = null; // map containing the different agent votes for a song private Map<IAgent, Double> agentWeights = null; public NextSongCalculationThread(BaseSong<BaseArtist, BaseAlbum> currentSong, AgentManager agentManager, IDbDataPortal dbDataPortal, PlayLog playLog, LogManager logManager, StatisticsProvider statisticsProvider, OtherDataProvider otherDataProvider) { this.currentSong = currentSong; this.agentManager = agentManager; this.dbDataPortal = dbDataPortal; this.playLog = playLog; this.logManager = logManager; this.statisticsProvider = statisticsProvider; this.otherDataProvider = otherDataProvider; } @Override public void run() { try { throwIfAborted(); TimingLogger timingLogger = new TimingLogger(getTag(), "next"); // Begin an immediate transaction dbDataPortal.beginTransaction(); timingLogger.addSplit("Transaction start"); Set<IAgent> agents = agentManager.getAgents(); agentWeights = agentManager.getAgentWeights(); // Enter a fake rating entry to calculate the next song on the predicted future Integer meSongId = null; if (currentSong != null) { double fractionPlayed = (getCalculationCase() == Case.Positive) ? 0.66d : 0.33d; playLog.writeToPlayLog(new Date(), new PlaylistSong<BaseArtist, BaseAlbum>(currentSong, SongSource.SMART_SHUFFLE), true, (int) (currentSong.getDuration() * fractionPlayed)); try { meSongId = otherDataProvider.getMusicExplorerSongId(currentSong); } catch (DataUnavailableException e) { Log.w(getTag(), e); } } timingLogger.addSplit("Rating entry"); // Get the song proposals AgentsTiming proposalTimes = new AgentsTiming(); { proposedSongs = getProposedSongs(proposalTimes); } timingLogger.addSplit("Proposals"); // Get the agent votes for the proposals Map<IAgent, List<SongVote>> agentVotes; AgentsTiming voteTimes = new AgentsTiming(); { agentVotes = new HashMap<IAgent, List<SongVote>>(); songVotes = getVotesForProposed(proposedSongs, agentVotes, voteTimes); } timingLogger.addSplit("Votes"); // Fill the song->agentVotes map agentVotesBySong = new HashMap<BaseSong<BaseArtist, BaseAlbum>, Map<IAgent, Float>>(proposedSongs.size()); for (BaseSong<BaseArtist, BaseAlbum> song : proposedSongs) { agentVotesBySong.put(song, new HashMap<IAgent, Float>(agents.size())); } for (Map.Entry<IAgent, List<SongVote>> entry : agentVotes.entrySet()) { for (SongVote vote : entry.getValue()) { Map<IAgent, Float> agentVote = agentVotesBySong.get(vote.getSong()); agentVote.put(entry.getKey(), vote.getVote()); } } // Add long time not listened songs addLongTimeNotListenedSongProposals(); /*// Order the next songs by rating Collections.sort(proposedSongs, new Comparator<BaseSong<BaseArtist, BaseAlbum>>() { @Override public int compare(BaseSong<BaseArtist, BaseAlbum> left, BaseSong<BaseArtist, BaseAlbum> right) { Double leftVote = songVotes.get(left); Double rightVote = songVotes.get(right); return rightVote.compareTo(leftVote); } });*/ // Randomize the song proposals by their vote proposedSongs = getShuffledSongsAtWeightedRandom(songVotes); // FIXME @sämy: this is only for debugging StringBuffer proposalsSb = new StringBuffer(); proposalsSb.append("song ident:overall vote"); for (IAgent agent : agents) { proposalsSb.append(':'); proposalsSb.append(agent.getIdentifier()); proposalsSb.append(String.format(" (%.2f)", agentWeights.get(agent))); } proposalsSb.append('|'); for (BaseSong<BaseArtist, BaseAlbum> song : proposedSongs) { proposalsSb.append(song); // song ident proposalsSb.append(':'); proposalsSb.append(String.format("%.3f", songVotes.get(song))); // overall vote Map<IAgent, Float> agentVotes2 = agentVotesBySong.get(song); for (IAgent agent : agents) { proposalsSb.append(String.format(":%.3f", agentVotes2.get(agent))); } proposalsSb.append('|'); } Log.d(getTag(), "Proposals: " + proposalsSb.toString()); // end debug only // never ever call dbDataPortal.setTransactionSucessful() !! dbDataPortal.endTransaction(); // ROLLBACK the transaction. Things below that line get written persistently to the db! // Write a log entry about this run NextSongCalculationLogEntry.Builder log = NextSongCalculationLogEntry.createInstance() .setPredictionCase(getCalculationCase()) .setCurrentSong((meSongId != null) ? meSongId : 0) .setProposalTimes(proposalTimes) .setVoteTimes(voteTimes); logManager.addLogEntry(log.build()); // Write the time used in the different parts to the debug log timingLogger.dumpToLog(); } catch (AbortException e) { // Just ignore it, we want to land here } finally { if (dbDataPortal.inTransaction()) { dbDataPortal.endTransaction(); // never ever call dbDataPortal.setTransactionSucessful() !! } } } /** * Adds songs which did not get any rating data assigned for a long time (or never) to the {proposedSongs} list. * This ensures reaching regions in the music space which are not explored yet (or have been forgotten about).<br/> * The agents vote for these songs is set to {@value #LONG_NOT_RATED_SONG_VOTE}. * * @param proposedSongs * The proposal list * @param agentVotes * The votes list */ private void addLongTimeNotListenedSongProposals() { // Fetch long not rated songs List<BaseSong<BaseArtist, BaseAlbum>> longNotRatedSongs; try { longNotRatedSongs = statisticsProvider.getLongNotRatedSongs( LONG_NOT_RATED_SONG_COUNT, LONG_NOT_RATED_THRESHOLD); } catch (DataUnavailableException e) { // Just ignore the warning and do not add any songs Log.w(getTag(), e); longNotRatedSongs = new LinkedList<BaseSong<BaseArtist, BaseAlbum>>(); } // Prepare agent votes for songs Map<IAgent, Float> agentVotesForProposedSongs = new HashMap<IAgent, Float>(); for (IAgent agent : agentManager.getAgents()) { agentVotesForProposedSongs.put(agent, (float) LONG_NOT_RATED_SONG_VOTE); } // Add long not played songs as proposals for (BaseSong<BaseArtist, BaseAlbum> song : longNotRatedSongs) { proposedSongs.add(song); songVotes.put(song, LONG_NOT_RATED_SONG_VOTE); agentVotesBySong.put(song, agentVotesForProposedSongs); } } /** * Returns the set of songs which are proposed by the agents. * * @param proposalTimes * @return The songs */ private List<BaseSong<BaseArtist, BaseAlbum>> getProposedSongs(AgentsTiming proposalTimes) { Set<IAgent> agents = agentManager.getAgents(); // Using set here to ensure no duplicate entries Set<BaseSong<BaseArtist, BaseAlbum>> proposedSongs = new HashSet<BaseSong<BaseArtist, BaseAlbum>>( SONG_PROPOSAL_USAGE_COUNT * agents.size()); for (IAgent agent : agents) { throwIfAborted(); StopWatch stopWatch = StopWatch.start(); List<BaseSong<BaseArtist, BaseAlbum>> agentProposals = new ArrayList<BaseSong<BaseArtist, BaseAlbum>>( agent.suggestSongs(SONG_PROPOSAL_COUNT)); // Get #SONG_PROPOSAL_USAGE_COUNT items of them at random while (agentProposals.size() > SONG_PROPOSAL_USAGE_COUNT) { agentProposals.remove(RandomProvider.getRandom().nextInt(agentProposals.size())); } proposedSongs.addAll(agentProposals); proposalTimes.addAgentTiming(agent, stopWatch.stop()); } return new ArrayList<BaseSong<BaseArtist, BaseAlbum>>(proposedSongs); } /** * Returns the average votes of the agents for the given songs. The votes gets weighted by the importance of the * agents. * * @param proposedSongs * The proposed songs * @param agentsVotes * (out) The votes for all the songs by agent * @param voteTimes * @return The weighted mean votes for the songs */ private Map<BaseSong<BaseArtist, BaseAlbum>, Double> getVotesForProposed( List<BaseSong<BaseArtist, BaseAlbum>> proposedSongs, Map<IAgent, List<SongVote>> agentsVotes, AgentsTiming voteTimes) { Set<IAgent> agents = agentManager.getAgents(); // Init the ratings list & the weight sum table Map<BaseSong<BaseArtist, BaseAlbum>, Double> votes = new HashMap<BaseSong<BaseArtist, BaseAlbum>, Double>(); Map<BaseSong<BaseArtist, BaseAlbum>, Double> weightSums = new HashMap<BaseSong<BaseArtist, BaseAlbum>, Double>( agents.size()); for (BaseSong<BaseArtist, BaseAlbum> song : proposedSongs) { votes.put(song, 0.0d); weightSums.put(song, 0.0d); } // Calculate the song ratings for (IAgent agent : agents) { throwIfAborted(); // Get the agent vote List<SongVote> agentVotes; StopWatch stopWatch = StopWatch.start(); { agentVotes = agent.vote(Collections.unmodifiableList(proposedSongs)); } voteTimes.addAgentTiming(agent, stopWatch.stop()); // Fill up the list to ensure that a vote for all songs exist List<BaseSong<BaseArtist, BaseAlbum>> noVote = new ArrayList<BaseSong<BaseArtist, BaseAlbum>>( proposedSongs); for (int i = 0; i < agentVotes.size();) { final SongVote vote = agentVotes.get(i); int idx = noVote.indexOf(vote.getSong()); if (idx >= 0) { noVote.remove(idx); ++i; } else { // No vote for this song is required agentVotes.remove(i); } } for (BaseSong<BaseArtist, BaseAlbum> song : noVote) { agentVotes.add(new SongVote(song, 0.0f)); } agentsVotes.put(agent, agentVotes); for (SongVote vote : agentVotes) { final BaseSong<BaseArtist, BaseAlbum> song = vote.getSong(); double oldRating = votes.get(song); double agentWeight = getAgentWeights().get(agent); double oldW = weightSums.get(song); double newW = oldW + agentWeight; double newRating = (oldRating * oldW + vote.getVote() * agentWeight) / newW; // Continuous, weighted mean calculation votes.put(song, newRating); weightSums.put(song, newW); } } return votes; } /** * Shuffles the song proposals at weighted random. For every position in the list * * <pre> * P[song choosen] = shifted_vote / votesSum * </pre> * * The votes get shifted to be pure positive and stretched, so that higher ratings are more probable to appear first * * <pre> * shifted_vote = (vote[song] + 1)<sup>STRETCH_FACTOR</sup> * </pre> * * @param votes * The song votes * @return The shuffled song list */ private List<BaseSong<BaseArtist, BaseAlbum>> getShuffledSongsAtWeightedRandom( Map<BaseSong<BaseArtist, BaseAlbum>, Double> votes) { final double STRETCH_FACTOR = 4.0f; Map<BaseSong<BaseArtist, BaseAlbum>, Double> remainingVotes = new HashMap<BaseSong<BaseArtist, BaseAlbum>, Double>( votes); // Calculate the total vote sum double voteSum = 0.0d; for (Map.Entry<BaseSong<BaseArtist, BaseAlbum>, Double> entry : remainingVotes.entrySet()) { double adjustedVote = Math.pow(entry.getValue() + 1.0d, STRETCH_FACTOR); // to not have negative votes remainingVotes.put(entry.getKey(), adjustedVote); voteSum += adjustedVote; } // Shuffle songs List<BaseSong<BaseArtist, BaseAlbum>> ret = new ArrayList<BaseSong<BaseArtist, BaseAlbum>>(votes.size()); while (ret.size() < votes.size()) { // Search the next weighted random song double rand = RandomProvider.getRandom().nextDouble() * voteSum; for (Map.Entry<BaseSong<BaseArtist, BaseAlbum>, Double> entry : remainingVotes.entrySet()) { if (rand <= entry.getValue()) { // Add the song ret.add(entry.getKey()); // Reduce the search space for the next round remainingVotes.remove(entry.getKey()); voteSum -= entry.getValue(); break; } else { rand -= entry.getValue(); } } } return ret; } /** * Returns the song with the maximal votes. * * @param votes * The votes list * @return The song */ /*private PlaylistSong<BaseArtist, BaseAlbum> getSongWithMaxVotes(Map<BaseSong<BaseArtist, BaseAlbum>, Double> votes) { PlaylistSong<BaseArtist, BaseAlbum> song = null; double maxVote = -1000; for (Map.Entry<BaseSong<BaseArtist, BaseAlbum>, Double> entry : votes.entrySet()) { if (entry.getValue() > maxVote) { maxVote = entry.getValue(); song = new PlaylistSong<BaseArtist, BaseAlbum>(entry.getKey(), SongSource.SMART_SHUFFLE); } } return song; }*/ /** * If this instance should calculate the negative or positive case. * * @return */ public abstract Case getCalculationCase(); /** * Returns true, if we should stop the calculation as soon as possible. * * @return */ public boolean isAborted() { return abortCalculation; } /** * Throws an {@link AbortException} if this thread should be aborted. */ protected void throwIfAborted() { if (isAborted()) { throw new AbortException(); } } /** * Stop the calculation as soon as possible. */ public void abortCalculation() { abortCalculation = true; } /** * True, if the calculation is finished and the proposed songs can be fetched. * * @return */ public boolean isReady() { return proposedSongs != null; } /** * Returns the i-th {@link #proposedSongs}. If the calculation is not ready yet, this method will return null. * * @param i * @return The i-th proposed song */ public PlaylistSong<BaseArtist, BaseAlbum> getProposedSong(int i) { if (proposedSongs == null) { return null; } if (i >= proposedSongs.size()) { return null; } return new PlaylistSong<BaseArtist, BaseAlbum>(proposedSongs.get(i), SongSource.SMART_SHUFFLE); } /** * Returns the agent votes for the i-th {@link #proposedSongs}. If the calculation is not ready yet, this method * will return null. * * @param i * @return The votes for the i-th proposed song */ public Map<IAgent, Float> getAgentVotes(int i) { if (agentVotesBySong == null) { return null; } return agentVotesBySong.get(getProposedSong(i)); } /** * Returns the weights of the agents at the time of the calculation. * * @return The weights */ public Map<IAgent, Double> getAgentWeights() { return agentWeights; } /** * Returns the song upon which the prediction is made. * * @return The song */ public BaseSong<BaseArtist, BaseAlbum> getCurrentSong() { return currentSong; } private String getTag() { return String.format("%s [%s]", NextSongCalculationThread.class.getSimpleName(), (getCalculationCase() == Case.Positive) ? "positive" : "negative"); } /** * Dummy exception which is thrown if we should not continue working after * {@link NextSongCalculationThread#abortCalculation()} is called. */ protected class AbortException extends RuntimeException { private static final long serialVersionUID = 7100275906852059482L; } }