io.github.oliviercailloux.grade.WeightingGrade Maven / Gradle / Ivy
The newest version!
package io.github.oliviercailloux.grade;
import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Preconditions.checkNotNull;
import static com.google.common.base.Verify.verify;
import com.google.common.base.MoreObjects;
import com.google.common.base.Verify;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Iterables;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.math.DoubleMath;
import io.github.oliviercailloux.grade.old.GradeStructure;
import io.github.oliviercailloux.grade.old.Mark;
import jakarta.json.bind.annotation.JsonbCreator;
import jakarta.json.bind.annotation.JsonbProperty;
import jakarta.json.bind.annotation.JsonbPropertyOrder;
import jakarta.json.bind.annotation.JsonbTransient;
import jakarta.json.bind.annotation.JsonbVisibility;
import java.util.Collection;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* containing positive points only and at one weights per sub-grade, at least one of which must be
* strictly positive. May order the grades (we may not want all positive then all negative but
* interleaving!). The positive weights are normalized internally. The negative weights must be in
* [−1, 0). A negative weight represents the prop. of the total points that can be lost on the
* corresponding criterion. Example: weight is −2/20, grade is 1 (means no penalty) or 0.5 (means
* −1/20) or 0 (means −2/20). A sub grade in the map may be an AdditiveGrade (even if it’s a
* penalty).
*
* This object authorizes zero weight because this can convey useful information. Assume a grade is
* a weighted sum of two exercices, with a weight that depends on something that can become zero for
* some students. Then the information of how good the second sub-grade is is useful, even if the
* weight is zero. (I had a case where a student could re-do an exercice, but the second attempt
* would have a low weight if the second attempt differed much from her first attempt, and possibly
* being zero; to prevent students from simply submitting a friend’s solution instead of correcting
* their own.) Another example is a test that shouldn’t count for points (for example, because the
* wording indicates that this aspect will not be considered) but whose result is considered
* informative feedback for the student anyway.
*
* {weights: {@code Map}} (non empty, all non null), this implementation
* has only the (normalized) weights and the marks and a comment, and generates the points.
*
* As the penalty has an absolute meaning, it is necessary that this object knows the best possible
* marks. As a convention, it is considered to be one for all sub-grades.
*
* Note that single children are not forbidden: this permits a node with two sub-criterion to remove
* one without losing the remaining criterion.
*
* @author Olivier Cailloux
*
*/
@JsonbPropertyOrder({"points", "comment", "subGrades"})
@JsonbVisibility(MethodVisibility.class)
public class WeightingGrade implements IGrade {
@SuppressWarnings("unused")
private static final Logger LOGGER = LoggerFactory.getLogger(WeightingGrade.class);
public static class PathGradeWeight {
public static PathGradeWeight given(CriteriaPath path, IGrade grade, double weight) {
return new PathGradeWeight(path, grade, weight);
}
private final CriteriaPath path;
private final IGrade grade;
private final double weight;
private PathGradeWeight(CriteriaPath path, IGrade grade, double weight) {
this.path = checkNotNull(path);
this.grade = checkNotNull(grade);
this.weight = weight;
}
public CriteriaPath getPath() {
return path;
}
public IGrade getGrade() {
return grade;
}
public double getWeight() {
return weight;
}
@Override
public boolean equals(Object o2) {
if (!(o2 instanceof WeightingGrade.PathGradeWeight)) {
return false;
}
final WeightingGrade.PathGradeWeight t2 = (WeightingGrade.PathGradeWeight) o2;
return path.equals(t2.path) && grade.equals(t2.grade) && weight == t2.weight;
}
@Override
public int hashCode() {
return Objects.hash(path, grade, weight);
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this).add("Path", path).add("Grade", grade)
.add("Weight", weight).toString();
}
}
public static class WeightedGrade {
public static WeightedGrade given(IGrade grade, double weight) {
if (grade instanceof Mark) {
final Mark mark = (Mark) grade;
return new WeightedMark(mark, weight);
}
return new WeightedGrade(grade, weight);
}
public static WeightedGrade given(Map subGrades) {
final WeightingGrade aggregated = WeightingGrade.fromWeightedGrades(subGrades);
return WeightedGrade.given(aggregated,
subGrades.values().stream().mapToDouble(WeightedGrade::getWeight).sum());
}
private final IGrade grade;
private final double weight;
protected WeightedGrade(IGrade grade, double weight) {
this.grade = checkNotNull(grade);
this.weight = weight;
checkArgument(Double.isFinite(weight));
checkArgument(weight >= 0d);
}
public IGrade getGrade() {
return grade;
}
public double getWeight() {
return weight;
}
public double getAbsolutePoints() {
return weight * grade.getPoints();
}
public WeightedMark getWeightedMark(CriteriaPath path) {
final WeightedMark weightedMark = grade.getWeightedMark(path);
return WeightedMark.given(weightedMark.getGrade(), weight * weightedMark.getWeight());
}
@Override
public boolean equals(Object o2) {
if (!(o2 instanceof WeightingGrade.WeightedGrade)) {
return false;
}
final WeightingGrade.WeightedGrade t2 = (WeightingGrade.WeightedGrade) o2;
return grade.equals(t2.grade) && weight == t2.weight;
}
@Override
public int hashCode() {
return Objects.hash(grade, weight);
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this).add("Grade", grade).add("Weight", weight).toString();
}
}
public static class WeightedMark extends WeightedGrade {
public static WeightedMark given(Mark mark, double weight) {
return new WeightedMark(mark, weight);
}
private WeightedMark(Mark mark, double weight) {
super(mark, weight);
}
@Override
public Mark getGrade() {
return (Mark) super.getGrade();
}
}
/**
* @param grades its key set iteration order is used to determine the order of the sub-grades.
* @param weights must have the same keys as the grades (but the iteration order is not used).
*/
public static WeightingGrade from(Map grades,
Map weights) {
return from(grades, weights, "");
}
public static WeightingGrade from(Map grades,
Map weights, String comment) {
return new WeightingGrade(grades, weights, comment);
}
public static WeightingGrade from(Collection grades) {
return from(grades, "");
}
/**
* @param grades its iteration order is used to determine the order of the sub-grades.
*/
public static WeightingGrade from(Collection grades, String comment) {
final ImmutableMap gradesByCriterion = grades.stream()
.collect(ImmutableMap.toImmutableMap((g) -> g.getCriterion(), (g) -> g.getGrade()));
final ImmutableMap weights = grades.stream()
.collect(ImmutableMap.toImmutableMap((g) -> g.getCriterion(), (g) -> g.getWeight()));
return new WeightingGrade(gradesByCriterion, weights, comment);
}
/**
* @param grades each of these grades will have an absolute weight given by its weight
* divided by the sum of all weights ; non empty; keys must be unrelated (if one is parent
* of another entry there is no way to use both grades!)
* @return a mark iff the map key set is the singleton ROOT and the (single) weighted grade is a
* weighted mark
*/
public static IGrade from(Map grades) {
return from(grades, false);
}
private static IGrade from(Map grades, boolean changeZeroChildren) {
checkArgument(!grades.isEmpty());
final Map modifiableGrades = new LinkedHashMap<>(grades);
LOGGER.debug("Initialized modifiable as: {}.", modifiableGrades);
final GradeStructure structure = GradeStructure.given(grades.keySet());
checkArgument(structure.getLeaves().equals(grades.keySet()));
/*
* We will populate modifiable grades from “right to left”, from children nodes to parent nodes,
* until reaching the root node. Note that we can’t simply stop when the map has only one
* remaining entry: at some point there may remain a single key "[a/b/c]", for example.
*/
while (!modifiableGrades.keySet().contains(CriteriaPath.ROOT)) {
/*
* We need to consider the highest depths first, because we want all siblings to be aggregated
* already.
*/
final CriteriaPath remainingPath =
modifiableGrades.keySet().stream().max(Comparator.comparing(CriteriaPath::size)).get();
LOGGER.debug("Considering {}.", remainingPath);
verify(!remainingPath.isRoot());
final CriteriaPath parent = remainingPath.withoutTail();
verify(!modifiableGrades.containsKey(parent));
final ImmutableSet childrenPaths = structure.getSuccessorPaths(parent);
{
/* Modifiable grades has entries for all children nodes. */
final ImmutableMap childrenWGrades = childrenPaths.stream()
.collect(ImmutableMap.toImmutableMap(CriteriaPath::getTail, modifiableGrades::get));
final boolean allZeroes = childrenWGrades.values().stream()
.mapToDouble(WeightedGrade::getWeight).allMatch(w -> w == 0d);
final ImmutableMap nonZeroesChildrenWGrades;
if (allZeroes) {
checkArgument(changeZeroChildren,
"Path has only zero-weight children, but I can’t rectify it: " + parent);
nonZeroesChildrenWGrades = ImmutableMap.copyOf(
Maps.transformValues(childrenWGrades, g -> WeightedGrade.given(g.getGrade(), 1d)));
} else {
nonZeroesChildrenWGrades = childrenWGrades;
}
final WeightedGrade aggregated =
WeightedGrade.given(fromWeightedGrades(nonZeroesChildrenWGrades),
childrenWGrades.values().stream().mapToDouble(WeightedGrade::getWeight).sum());
modifiableGrades.put(parent, aggregated);
LOGGER.debug("Added to {} aggregated {}.", parent, aggregated);
}
childrenPaths.stream().forEach(modifiableGrades::remove);
}
verify(modifiableGrades.keySet().equals(ImmutableSet.of(CriteriaPath.ROOT)));
final IGrade grade = Iterables.getOnlyElement(modifiableGrades.values()).getGrade();
final ImmutableSet expectedLeaves = grades.keySet().stream()
.flatMap(
p -> grades.get(p).getGrade().toTree().getLeaves().stream().map(l -> l.withPrefix(p)))
.collect(ImmutableSet.toImmutableSet());
verify(grade.toTree().getLeaves().equals(expectedLeaves));
return grade;
}
/**
* @param weightedGrades its key set iteration order is used to determine the order of the
* sub-grades.
*/
public static WeightingGrade fromWeightedGrades(Map weightedGrades) {
return from(Maps.toMap(weightedGrades.keySet(), c -> weightedGrades.get(c).getGrade()),
Maps.toMap(weightedGrades.keySet(), c -> weightedGrades.get(c).getWeight()), "");
}
/**
* @param grades its iteration order is used to determine the order of the sub-grades.
*/
@JsonbCreator
public static WeightingGrade fromList(
@JsonbProperty("subGrades") List grades,
@JsonbProperty("comment") String comment) {
/*
* The list type (rather than set) is required for json to deserialize in the right order.
*/
return from(grades, comment);
}
public static WeightingGrade proportional(Criterion c1, IGrade g1, Criterion c2, IGrade g2) {
return proportional(c1, g1, c2, g2, "");
}
public static WeightingGrade proportional(Criterion c1, IGrade g1, Criterion c2, IGrade g2,
String comment) {
return WeightingGrade.from(ImmutableMap.of(c1, g1, c2, g2), ImmutableMap.of(c1, 0.5d, c2, 0.5d),
comment);
}
public static WeightingGrade proportional(Criterion c1, IGrade g1, Criterion c2, IGrade g2,
Criterion c3, IGrade g3) {
return proportional(c1, g1, c2, g2, c3, g3, "");
}
public static WeightingGrade proportional(Criterion c1, IGrade g1, Criterion c2, IGrade g2,
Criterion c3, IGrade g3, String comment) {
return WeightingGrade.from(ImmutableMap.of(c1, g1, c2, g2, c3, g3),
ImmutableMap.of(c1, 1d / 3d, c2, 1d / 3d, c3, 1d / 3d), comment);
}
public static IGrade withZeroesRectified(Map grades) {
return from(grades, true);
}
private static final double MAX_MARK = 1d;
/**
* Not empty. This key set equals the key set of the weights.
*/
private final ImmutableMap subGrades;
/**
* The positive ones sum to one. No zero values (TODO not sure!).
*/
private final ImmutableMap weights;
private final String comment;
private WeightingGrade(Map subGrades, Map weights,
String comment) {
checkArgument(weights.values().stream().allMatch(d -> Double.isFinite(d)));
checkArgument(weights.values().stream().anyMatch(d -> d > 0d),
"Must have at least one non-zero weight.");
checkArgument(
subGrades.values().stream().allMatch(g -> 0d <= g.getPoints() && g.getPoints() <= 1d));
checkArgument(subGrades.keySet().equals(weights.keySet()),
String.format("Sub grades have keys: %s, weights have keys: %s, diff: %s",
subGrades.keySet(), weights.keySet(),
Sets.symmetricDifference(subGrades.keySet(), weights.keySet())));
final double sumPosWeights =
weights.values().stream().filter(d -> d > 0d).collect(Collectors.summingDouble(d -> d));
verify(sumPosWeights > 0d);
/* See JsonGradeTests#gradePrecisionRoundTrip. */
final double effectiveNormalizer;
if (DoubleMath.fuzzyEquals(1.0d, sumPosWeights, 1e-8d)) {
effectiveNormalizer = 1.0d;
} else {
effectiveNormalizer = sumPosWeights;
}
/*
* I iterate over the sub grades key set in order to guarantee iteration order of the weights
* reflects the order of the sub-grades.
*/
this.weights = subGrades.keySet().stream().collect(ImmutableMap.toImmutableMap(c -> c,
c -> weights.get(c) > 0d ? weights.get(c) / effectiveNormalizer : weights.get(c)));
this.subGrades = ImmutableMap.copyOf(subGrades);
this.comment = checkNotNull(comment);
}
@Override
public double getPoints() {
final double positivePoints =
weights.entrySet().stream().filter((e) -> e.getValue() > 0d).map(Entry::getKey).collect(
Collectors.summingDouble((c) -> subGrades.get(c).getPoints() * weights.get(c)));
final double negativePoints = weights.entrySet().stream().filter((e) -> e.getValue() < 0d)
.map(Entry::getKey).collect(Collectors
.summingDouble((c) -> (MAX_MARK - subGrades.get(c).getPoints()) * weights.get(c)));
Verify.verify(negativePoints <= 0d);
final double totalPoints = Math.max(0d, positivePoints + negativePoints);
Verify.verify(0d <= totalPoints && totalPoints <= 1d);
return totalPoints;
}
@Override
public String getComment() {
return comment;
}
@JsonbTransient
@Override
public ImmutableMap getSubGrades() {
return subGrades;
}
/**
* @return iterates in the order of the sub-grades.
*/
@Override
@JsonbProperty("subGrades")
public ImmutableSet getSubGradesAsSet() {
return subGrades.keySet().stream()
.map((c) -> CriterionGradeWeight.from(c, subGrades.get(c), weights.get(c)))
.collect(ImmutableSet.toImmutableSet());
}
@JsonbTransient
public ImmutableSet getAllSubGrades() {
return toTree().getPaths().stream()
.map(p -> PathGradeWeight.given(p, getGrade(p).get(), getWeight(p)))
.collect(ImmutableSet.toImmutableSet());
}
/**
* @return the weights, such that the positive weights sum to one, and not empty. Iterates in the
* order of the sub-grades.
*/
@Override
@JsonbTransient
public ImmutableMap getWeights() {
return weights;
}
/**
* 1 for a weighting grade whose subgrades are all marks.
*/
@Override
public IGrade limitedDepth(int depth) {
checkArgument(depth >= 0);
if (depth == 0) {
return Mark.given(getPoints(), getComment());
}
return WeightingGrade.from(
subGrades.keySet().stream().collect(
ImmutableMap.toImmutableMap(c -> c, c -> subGrades.get(c).limitedDepth(depth - 1))),
weights);
}
@Override
public IGrade withComment(String newComment) {
return new WeightingGrade(subGrades, weights, newComment);
}
@Override
public WeightingGrade withSubGrade(Criterion criterion, IGrade newSubGrade) {
return new WeightingGrade(GradeUtils.withUpdatedEntry(subGrades, criterion, newSubGrade),
weights, comment);
}
@Override
public boolean equals(Object o2) {
if (!(o2 instanceof IGrade)) {
return false;
}
IGrade g2 = (IGrade) o2;
return getPoints() == g2.getPoints() && getComment().equals(g2.getComment())
&& getSubGrades().equals(g2.getSubGrades());
}
@Override
public int hashCode() {
return Objects.hash(getPoints(), getComment(), getSubGrades());
}
@Override
public String toString() {
return MoreObjects.toStringHelper(this).add("points", getPoints()).add("comment", getComment())
.add("subGrades", getSubGradesAsSet()).toString();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy