/// <summary> /// Generates a model with the specified number of recipes and returns the recipe IDs in the optimal order. /// </summary> /// <param name="recipes">Number of recipes to generate</param> /// <param name="scale">Scale indicating importance of optimal ingredient usage vs. user trend usage. 1 indicates ignore user trends, return most efficient set of recipes. 5 indicates ignore pantry and generate recipes user is most likely to rate high.</param> /// <returns>An array up to size "recipes" containing recipes from DBSnapshot.</returns> public Model Generate(int recipes, byte scale) { if (recipes > MAX_SUGGESTIONS) { throw new ArgumentException("Modeler can only generate " + MAX_SUGGESTIONS.ToString() + " recipes at a time."); } var temperature = 10000.0; double deltaScore = 0; const double absoluteTemperature = 0.00001; totals = new Dictionary <IngredientNode, IngredientUsage>(IngredientNode.NextKey); var currentSet = new RecipeNode[recipes]; //current set of recipes var nextSet = new RecipeNode[recipes]; //set to compare with current InitSet(currentSet); //Initialize with n random recipes var score = GetScore(currentSet, scale); //Check initial score var timer = new Stopwatch(); timer.Start(); while (temperature > absoluteTemperature) { nextSet = GetNextSet(currentSet); //Swap out a random recipe with another one from the available pool deltaScore = GetScore(nextSet, scale) - score; //if the new set has a smaller score (good thing) //or if the new set has a higher score but satisfies Boltzman condition then accept the set if ((deltaScore < 0) || (score > 0 && Math.Exp(-deltaScore / temperature) > random.NextDouble())) { nextSet.CopyTo(currentSet, 0); score += deltaScore; } //cool down the temperature temperature *= COOLING_RATE; } timer.Stop(); Log.InfoFormat("Generating set of {0} recipes took {1}ms.", recipes, timer.ElapsedMilliseconds); return(new Model(currentSet, profile.Pantry, score)); }
/// <summary> /// Swap out a random recipe with another one from the available pool /// </summary> RecipeNode[] GetNextSet(RecipeNode[] currentSet) { var rndIndex = random.Next(currentSet.Length); var existingRecipe = currentSet[rndIndex]; RecipeNode newRecipe; var timeout = 0; while (true) { if (++timeout > 100) //We've tried 100 times to replace a recipe in this set, but cannot find anything that isn't already in this set. { throw new ImpossibleQueryException(); //TODO: If this is the only set of n which match that query, we've solved it - just return this set as final! } newRecipe = Fish(); if (newRecipe == existingRecipe) { continue; } var fFound = false; for (var i = 0; i < currentSet.Length; i++) { if (newRecipe == currentSet[i]) { fFound = true; break; } } if (!fFound) { break; } } var retSet = new RecipeNode[currentSet.Length]; currentSet.CopyTo(retSet, 0); retSet[rndIndex] = newRecipe; return(retSet); }
public Model(RecipeNode[] recipes, PantryItem[] pantry, double score) { this.recipeids = recipes.Select(r => r.RecipeId).ToArray(); this.pantry = pantry; this.score = score; }
/// <summary> /// Judges a set of recipes based on a scale and its efficiency with regards to the current pantry. The lower the score, the better. /// </summary> double GetScore(RecipeNode[] currentSet, byte scale) { double wasted = 0; //Add 1.0 for ingredients that don't exist in pantry, add percentage of leftover otherwise float avgRating = 0; //Average rating for all recipes in the set (0-4) float tagPoints = 0; //Point for each tag that's one of our favorites float tagTotal = 0; //Total number of tags in all recipes float ingPoints = 0; //Point for each ing that's one of our favorites float ingTotal = 0; //Total number of ingrediets in all recipes for (var i = 0; i < currentSet.Length; i++) { var recipe = currentSet[i]; var ingredients = (IngredientUsage[]) recipe.Ingredients; //Add points for any favorite tags this recipe uses tagTotal += recipe.Tags.Length; ingTotal += ingredients.Length; for (var t = 0; t < recipe.Tags.Length; t++) //TODO: Use bitmasks for storing recipe tags and fav tags, then count bits { if (favTags[t]) { tagPoints++; } } byte realRating; //Real rating is the user's rating, else the public rating, else 3. if (!ratings.TryGetValue(recipe, out realRating)) { realRating = (recipe.Rating == 0) ? (byte) 3 : recipe.Rating; //if recipe has no ratings, use average rating of 3. } avgRating += (realRating - 1); for (var j = 0; j < ingredients.Length; j++) { var ingredient = ingredients[j]; //Add points for any favorite ingredients this recipe uses var ingKey = ingredient.Ingredient.Key; for (var k = 0; k < favIngs.Length; k++) //For loop is actually faster than 5 "if" statements { if (favIngs[k] == ingKey) { ingPoints++; break; } } IngredientUsage curUsage; var fContains = totals.TryGetValue(ingredient.Ingredient, out curUsage); if (!fContains) { curUsage = new IngredientUsage(); curUsage.Amt = ingredient.Amt; totals.Add(ingredient.Ingredient, curUsage); } else { curUsage.Amt += ingredient.Amt; } } } if (profile.Pantry != null) //If profile has a pantry, figure out how much of it is wasted { //For each pantry ingredient that we're not using, punish the score by MISSING_ING_PUNISH amount. var pEnum = pantryAmounts.GetEnumerator(); while (pEnum.MoveNext()) { if (!totals.ContainsKey(pEnum.Current.Key)) { wasted += MISSING_ING_PUNISH; } } var e = totals.GetEnumerator(); while (e.MoveNext()) { var curKey = e.Current.Key; float? have; if (pantryAmounts.TryGetValue(curKey, out have)) //We have this in our pantry { if (!have.HasValue) //We have this in our pantry, but no amount is specified - So we "act" like we have whatever we need { continue; } if (!e.Current.Value.Amt.HasValue) //This recipe doesn't specify an amount - So we "act" like we use half of what we have { wasted += EMPTY_RECIPE_AMOUNT; continue; } var need = e.Current.Value.Amt.Value; var ratio = 1 - ((have.Value - need)/have.Value); //Percentage of how much you're using of what you have if (ratio > 1) //If you need more than you have, add the excess ratio to the waste but don't go over the punishment for not having the ingredient at all { wasted += Math.Min(ratio, NEW_ING_PUNISH); } else { wasted += (1 - ratio); } } else { wasted += NEW_ING_PUNISH; //For each ingredient this meal set needs that we don't have, increment by NEW_ING_PUNISH } } } double worstScore, trendScore, efficiencyScore; if (profile.Pantry == null) //No pantry, efficiency is defined by the overlap of ingredients across recipes { efficiencyScore = totals.Keys.Count/ingTotal; } else //Efficiency is defined by how efficient the pantry ingredients are utilized { worstScore = ((totals.Keys.Count*NEW_ING_PUNISH) + (profile.Pantry.Length*MISSING_ING_PUNISH)); //Worst possible efficiency score efficiencyScore = (wasted/worstScore); } avgRating /= currentSet.Length; trendScore = 1 - ((((avgRating/4)*4) + (tagPoints/tagTotal) + (ingPoints/ingTotal))/6); totals.Clear(); if (scale == 1) return efficiencyScore; else if (scale == 2) return (efficiencyScore + efficiencyScore + trendScore)/3; else if (scale == 3) return (efficiencyScore + trendScore)/2; else if (scale == 4) return (efficiencyScore + trendScore + trendScore)/3; else if (scale == 5) return trendScore; return 0; }
/// <summary> /// Initializes currentSet with random recipes from the available recipe pool. /// </summary> void InitSet(RecipeNode[] currentSet) { var inUse = new List<Guid>(currentSet.Length); for (var i = 0; i < currentSet.Length; i++) { RecipeNode g; var timeout = 0; do { g = Fish(); if (++timeout > 100) //Ok we've tried 100 times to find a recipe not already in this set, there just isn't enough data to work with for this query { throw new ImpossibleQueryException(); //TODO: Maybe we can lower the demanded meals and return what we do have } } while (inUse.Contains(g.RecipeId)); inUse.Add(g.RecipeId); currentSet[i] = g; } }
/// <summary> /// Generates a model with the specified number of recipes and returns the recipe IDs in the optimal order. /// </summary> /// <param name="recipes">Number of recipes to generate</param> /// <param name="scale">Scale indicating importance of optimal ingredient usage vs. user trend usage. 1 indicates ignore user trends, return most efficient set of recipes. 5 indicates ignore pantry and generate recipes user is most likely to rate high.</param> /// <returns>An array up to size "recipes" containing recipes from DBSnapshot.</returns> public Model Generate(int recipes, byte scale) { if (recipes > MAX_SUGGESTIONS) { throw new ArgumentException("Modeler can only generate " + MAX_SUGGESTIONS.ToString() + " recipes at a time."); } var temperature = 10000.0; double deltaScore = 0; const double absoluteTemperature = 0.00001; totals = new Dictionary<IngredientNode, IngredientUsage>(IngredientNode.NextKey); var currentSet = new RecipeNode[recipes]; //current set of recipes var nextSet = new RecipeNode[recipes]; //set to compare with current InitSet(currentSet); //Initialize with n random recipes var score = GetScore(currentSet, scale); //Check initial score var timer = new Stopwatch(); timer.Start(); while (temperature > absoluteTemperature) { nextSet = GetNextSet(currentSet); //Swap out a random recipe with another one from the available pool deltaScore = GetScore(nextSet, scale) - score; //if the new set has a smaller score (good thing) //or if the new set has a higher score but satisfies Boltzman condition then accept the set if ((deltaScore < 0) || (score > 0 && Math.Exp(-deltaScore/temperature) > random.NextDouble())) { nextSet.CopyTo(currentSet, 0); score += deltaScore; } //cool down the temperature temperature *= COOLING_RATE; } timer.Stop(); Log.InfoFormat("Generating set of {0} recipes took {1}ms.", recipes, timer.ElapsedMilliseconds); return new Model(currentSet, profile.Pantry, score); }
/// <summary> /// Swap out a random recipe with another one from the available pool /// </summary> RecipeNode[] GetNextSet(RecipeNode[] currentSet) { var rndIndex = random.Next(currentSet.Length); var existingRecipe = currentSet[rndIndex]; RecipeNode newRecipe; var timeout = 0; while (true) { if (++timeout > 100) //We've tried 100 times to replace a recipe in this set, but cannot find anything that isn't already in this set. { throw new ImpossibleQueryException(); //TODO: If this is the only set of n which match that query, we've solved it - just return this set as final! } newRecipe = Fish(); if (newRecipe == existingRecipe) { continue; } var fFound = false; for (var i = 0; i < currentSet.Length; i++) { if (newRecipe == currentSet[i]) { fFound = true; break; } } if (!fFound) { break; } } var retSet = new RecipeNode[currentSet.Length]; currentSet.CopyTo(retSet, 0); retSet[rndIndex] = newRecipe; return retSet; }
public Model(RecipeNode[] recipes, PantryItem[] pantry, double score) { this._recipeids = recipes.Select(r => { return r.RecipeId; }).ToArray(); this._pantry = pantry; this._score = score; }
/// <summary> /// Judges a set of recipes based on a scale and its efficiency with regards to the current pantry. The lower the score, the better. /// </summary> public double GetScore(RecipeNode[] currentSet, byte scale) { double wasted = 0; // Add 1.0 for ingredients that don't exist in pantry, add percentage of leftover otherwise float avgRating = 0; // Average rating for all recipes in the set (0-4) float tagPoints = 0; // Point for each tag that's one of our favorites float tagTotal = 0; // Total number of tags in all recipes float ingPoints = 0; // Point for each ing that's one of our favorites float ingTotal = 0; // Total number of ingrediets in all recipes for (var i = 0; i < currentSet.Length; i++) { var recipe = currentSet[i]; var ingredients = (IngredientUsage[])recipe.Ingredients; // Add points for any favorite tags this recipe uses tagTotal += recipe.Tags.Length; ingTotal += ingredients.Length; for (var t = 0; t < recipe.Tags.Length; t++) { if (this.favTags[t]) { tagPoints++; } } byte realRating; // Real rating is the user's rating, else the public rating, else 3. if (!this.ratings.TryGetValue(recipe, out realRating)) { realRating = (recipe.Rating == 0) ? (byte)3 : recipe.Rating; // if recipe has no ratings, use average rating of 3. } avgRating += realRating - 1; foreach (var ingredient in ingredients) { // Add points for any favorite ingredients this recipe uses var ingKey = ingredient.Ingredient.Key; if (this.favIngs.Any(t => t == ingKey)) { ingPoints++; } IngredientUsage curUsage; var fContains = this.totals.TryGetValue(ingredient.Ingredient, out curUsage); if (!fContains) { curUsage = new IngredientUsage(); curUsage.Amt = ingredient.Amt; this.totals.Add(ingredient.Ingredient, curUsage); } else { curUsage.Amt += ingredient.Amt; } } } // If profile has a pantry, figure out how much of it is wasted if (this.profile.Pantry != null) { // For each pantry ingredient that we're not using, punish the score by MISSING_ING_PUNISH amount. var pEnum = this.pantryAmounts.GetEnumerator(); while (pEnum.MoveNext()) { if (!this.totals.ContainsKey(pEnum.Current.Key)) { wasted += MissingIngPunish; } } var e = this.totals.GetEnumerator(); while (e.MoveNext()) { var curKey = e.Current.Key; float? have; // We have this in our pantry if (this.pantryAmounts.TryGetValue(curKey, out have)) { // We have this in our pantry, but no amount is specified - So we "act" like we have whatever we need if (!have.HasValue) { continue; } // This recipe doesn't specify an amount - So we "act" like we use half of what we have if (!e.Current.Value.Amt.HasValue) { wasted += EmptyRecipeAmount; continue; } var need = e.Current.Value.Amt.Value; var ratio = 1 - ((have.Value - need) / have.Value); // Percentage of how much you're using of what you have // If you need more than you have, add the excess ratio to the waste but don't go over the punishment for not having the ingredient at all if (ratio > 1) { wasted += Math.Min(ratio, NewIngPunish); } else { wasted += 1 - ratio; } } else { wasted += NewIngPunish; // For each ingredient this meal set needs that we don't have, increment by NEW_ING_PUNISH } } } double efficiencyScore; // No pantry, efficiency is defined by the overlap of ingredients across recipes if (this.profile.Pantry == null) { efficiencyScore = totals.Keys.Count / ingTotal; } else { // Efficiency is defined by how efficient the pantry ingredients are utilized double worstScore = (this.totals.Keys.Count * NewIngPunish) + (this.profile.Pantry.Length * MissingIngPunish); efficiencyScore = wasted / worstScore; } avgRating /= currentSet.Length; double trendScore = 1 - ((((avgRating / 4) * 4) + (tagPoints / tagTotal) + (ingPoints / ingTotal)) / 6); this.totals.Clear(); switch (scale) { case 1: return efficiencyScore; case 2: return (efficiencyScore + efficiencyScore + trendScore) / 3; case 3: return (efficiencyScore + trendScore) / 2; case 4: return (efficiencyScore + trendScore + trendScore) / 3; case 5: return trendScore; } return 0; }
/// <summary> /// Generates a model with the specified number of recipes and returns the recipe IDs in the optimal order. /// </summary> /// <param name="recipes">Number of recipes to generate</param> /// <param name="scale">Scale indicating importance of optimal ingredient usage vs. user trend usage. 1 indicates ignore user trends, return most efficient set of recipes. 5 indicates ignore pantry and generate recipes user is most likely to rate high.</param> /// <returns>An array up to size "recipes" containing recipes from DBSnapshot.</returns> public Model Generate(int recipes, byte scale) { if (recipes > MaxSuggestions) { throw new ArgumentException("Modeler can only generate " + MaxSuggestions + " recipes at a time."); } var temperature = 10000.0; const double AbsoluteTemperature = 0.00001; this.totals = new Dictionary<IngredientNode, IngredientUsage>(IngredientNode.NextKey); var currentSet = new RecipeNode[recipes]; // current set of recipes this.InitSet(currentSet); // Initialize with n random recipes var score = this.GetScore(currentSet, scale); // Check initial score var timer = new Stopwatch(); timer.Start(); while (temperature > AbsoluteTemperature) { var nextSet = this.GetNextSet(currentSet); // set to compare with current var deltaScore = this.GetScore(nextSet, scale) - score; // if the new set has a smaller score (good thing) // or if the new set has a higher score but satisfies Boltzman condition then accept the set if ((deltaScore < 0) || (score > 0 && Math.Exp(-deltaScore / temperature) > this.random.NextDouble())) { nextSet.CopyTo(currentSet, 0); score += deltaScore; } // cool down the temperature temperature *= CoolingRate; } timer.Stop(); Log.InfoFormat("Generating set of {0} recipes took {1}ms.", recipes, timer.ElapsedMilliseconds); return new Model(currentSet, this.profile.Pantry, score); }
/// <summary> /// Judges a set of recipes based on a scale and its efficiency with regards to the current pantry. The lower the score, the better. /// </summary> private double GetScore(RecipeNode[] currentSet, byte scale) { double wasted = 0; // Add 1.0 for ingredients that don't exist in pantry, add percentage of leftover otherwise float averageRating = 0; // Average rating for all recipes in the set (0-4) float tagPoints = 0; // Point for each tag that's one of our favorites float tagTotal = 0; // Total number of tags in all recipes float ingredientPoints = 0; // Point for each ingredient that's one of our favorites float ingredientTotal = 0; // Total number of ingrediets in all recipes for (var i = 0; i < currentSet.Length; i++) { var recipe = currentSet[i]; var ingredients = (IngredientUsage[])recipe.Ingredients; // Add points for any favorite tags this recipe uses tagTotal += recipe.Tags.Length; ingredientTotal += ingredients.Length; // TODO: Use bitmasks for storing recipe tags and fav tags, then count bits for (int tag = 0; tag < recipe.Tags.Length; tag++) { if (this.favoriteTags[tag]) { tagPoints++; } } // Real rating is the user's rating, else the public rating, else 3. byte realRating; if (!this.ratings.TryGetValue(recipe, out realRating)) { // if recipe has no ratings, use average rating of 3. realRating = (recipe.Rating == 0) ? (byte)3 : recipe.Rating; } averageRating += realRating - 1; for (var j = 0; j < ingredients.Length; j++) { var ingredient = ingredients[j]; // Add points for any favorite ingredients this recipe uses var ingredientKey = ingredient.Ingredient.Key; for (var key = 0; key < this.favoriteIngredients.Length; key++) { if (this.favoriteIngredients[key] == ingredientKey) { ingredientPoints++; break; } } IngredientUsage currentUsage; bool fContains = this.totals.TryGetValue(ingredient.Ingredient, out currentUsage); if (!fContains) { currentUsage = new IngredientUsage(); currentUsage.Amount = ingredient.Amount; this.totals.Add(ingredient.Ingredient, currentUsage); } else { currentUsage.Amount += ingredient.Amount; } } } // If profile has a pantry, figure out how much of it is wasted if (this.profile.Pantry != null) { // For each pantry ingredient that we're not using, punish the score by MissingIngredientPunish amount. var pantryEnumerator = this.pantryAmounts.GetEnumerator(); while (pantryEnumerator.MoveNext()) { if (!this.totals.ContainsKey(pantryEnumerator.Current.Key)) { wasted += MissingIngredientPunish; } } var enumerator = this.totals.GetEnumerator(); while (enumerator.MoveNext()) { var currentKey = enumerator.Current.Key; float? haveAmount; // We have this in our pantry if (this.pantryAmounts.TryGetValue(currentKey, out haveAmount)) { // We have this in our pantry, but no amount is specified - So we "act" like we have whatever we need if (!haveAmount.HasValue) { continue; } // This recipe doesn't specify an amount - So we "act" like we use half of what we have if (!enumerator.Current.Value.Amount.HasValue) { wasted += EmptyRecipeAmount; continue; } float needAmount = enumerator.Current.Value.Amount.Value; // Percentage of how much you're using of what you have float ratio = 1 - ((haveAmount.Value - needAmount) / haveAmount.Value); // If you need more than you have, add the excess ratio to the waste but don't go over the punishment for not having the ingredient at all if (ratio > 1) { wasted += Math.Min(ratio, NewIngredientPunish); } else { wasted += 1 - ratio; } } else { // For each ingredient this meal set needs that we don't have, increment by NewIngredientPunish wasted += NewIngredientPunish; } } } double worstScore; double trendScore; double efficiencyScore; // No pantry, efficiency is defined by the overlap of ingredients across recipes if (this.profile.Pantry == null) { efficiencyScore = this.totals.Keys.Count / ingredientTotal; } else { // Efficiency is defined by how efficient the pantry ingredients are utilized worstScore = this.totals.Keys.Count * NewIngredientPunish + this.profile.Pantry.Length * MissingIngredientPunish; // Worst possible efficiency score efficiencyScore = wasted / worstScore; } averageRating /= currentSet.Length; trendScore = 1 - ((averageRating + tagPoints / tagTotal + ingredientPoints / ingredientTotal) / 6); this.totals.Clear(); switch (scale) { case 1: return efficiencyScore; case 2: return (efficiencyScore + efficiencyScore + trendScore) / 3; case 3: return (efficiencyScore + trendScore) / 2; case 4: return (efficiencyScore + trendScore + trendScore) / 3; case 5: return trendScore; default: return 0; } }
/// <summary> /// Finds a random recipe in the available recipe pool /// </summary> /// <returns></returns> private RecipeNode Fish() { RecipeNode recipeNode = null; // No pantry, fish through Recipe index if (this.pantryIngredients == null) { var tag = (this.allowedTags == null) ? this.random.Next(Enum.GetNames(typeof(RecipeTag)).Length) : (int)this.allowedTags[this.random.Next(this.allowedTags.Length)]; var recipesByTag = this.db.FindRecipesByTag(tag); // Nothing in that tag if (recipesByTag == null || recipesByTag.Length == 0) { return(Fish()); } var rnd = this.random.Next(recipesByTag.Length); recipeNode = recipesByTag[rnd]; } else { // Fish through random pantry ingredient var rndIng = this.random.Next(this.pantryIngredients.Length); var ingNode = this.pantryIngredients[rndIng]; RecipeNode[] recipes; if (this.allowedTags != null && this.allowedTags.Length > 0) { // Does this ingredient have any allowed tags? if (!this.allowedTags.Intersect(ingNode.AvailableTags).Any()) { return(this.Fish()); // No - Find something else } // Pick random tag from allowed tags (since this set is smaller, better to guess an available tag) while (true) { var t = this.random.Next(this.allowedTags.Length); // NOTE: Next will NEVER return MaxValue, so we don't subtract 1 from Length! var rndTag = this.allowedTags[t]; recipes = ingNode.RecipesByTag[(int)rndTag] as RecipeNode[]; if (recipes != null) { break; } } } else { // Just pick a random available tag var rndTag = this.random.Next(ingNode.AvailableTags.Length); var tag = ingNode.AvailableTags[rndTag]; recipes = ingNode.RecipesByTag[(int)tag] as RecipeNode[]; } if (recipes != null) { var rndRecipe = this.random.Next(recipes.Length); recipeNode = recipes[rndRecipe]; } } // If there's a blacklist, make sure no ingredients are blacklisted otherwise try again if (this.ingBlacklist != null) { if (recipeNode != null) { var ingredients = (IngredientUsage[])recipeNode.Ingredients; for (var i = 0; i < ingredients.Length; i++) { if (this.ingBlacklist.Contains(ingredients[i].Ingredient)) { return(this.Fish()); } } } } // Discard if this recipe is to be avoided if (recipeNode != null && (this.profile.AvoidRecipe.HasValue && this.profile.AvoidRecipe.Value.Equals(recipeNode.RecipeId))) { return(this.Fish()); } return(recipeNode); }