From 82e9c4f626beaab675e6a3f9794fba9dd3c668f8 Mon Sep 17 00:00:00 2001 From: Adrien Matissart Date: Sat, 1 Jun 2024 18:41:10 +0200 Subject: [PATCH 1/5] [solidago] gbt: estimate asymmetrical uncertainties based on increase of neg. log likelihood by 1 (#1973) --------- Co-authored-by: Louis Faucon --- .../src/solidago/preference_learning/base.py | 37 ++-- .../generalized_bradley_terry.py | 166 ++++++++------- .../lbfgs_generalized_bradley_terry.py | 194 ++++++++---------- solidago/src/solidago/solvers/dichotomy.py | 7 +- solidago/src/solidago/solvers/optimize.py | 66 ++++-- solidago/tests/data/data_1.py | 20 +- solidago/tests/data/data_2.py | 54 +++-- solidago/tests/data/data_3.py | 73 ++++--- solidago/tests/test_solvers.py | 34 ++- 9 files changed, 363 insertions(+), 288 deletions(-) diff --git a/solidago/src/solidago/preference_learning/base.py b/solidago/src/solidago/preference_learning/base.py index 5c9ac77a0b..fe998b5509 100644 --- a/solidago/src/solidago/preference_learning/base.py +++ b/solidago/src/solidago/preference_learning/base.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Optional, Union +from typing import Optional import pandas as pd import logging @@ -10,21 +10,24 @@ logger = logging.getLogger(__name__) + class PreferenceLearning(ABC): + MAX_UNCERTAINTY = 1000.0 + def __call__( - self, + self, judgments: Judgments, users: pd.DataFrame, entities: pd.DataFrame, initialization: Optional[dict[int, ScoringModel]] = None, new_judgments: Optional[Judgments] = None, ) -> dict[int, ScoringModel]: - """ Learns a scoring model, given user judgments of entities - + """Learns a scoring model, given user judgments of entities + Parameters ---------- user_judgments: dict[str, pd.DataFrame] - May contain different forms of judgments, + May contain different forms of judgments, but most likely will contain "comparisons" and/or "assessments" entities: DataFrame with columns * entity_id: int, index @@ -34,14 +37,14 @@ def __call__( It is not supposed to affect the output of the training new_judgments: New judgments This allows to prioritize coordinate descent, starting with newly evaluated entities - + Returns ------- user_models: dict[int, ScoringModel] user_models[user] is the learned scoring model for user """ assert isinstance(judgments, Judgments) - + user_models = dict() if initialization is None else initialization for n_user, user in enumerate(users.index): if n_user % 100 == 0: @@ -54,21 +57,21 @@ def __call__( new_judg = None if new_judgments is None else new_judgments[user] user_models[user] = self.user_learn(judgments[user], entities, init_model, new_judg) return user_models - + @abstractmethod def user_learn( - self, + self, user_judgments: dict[str, pd.DataFrame], entities: pd.DataFrame, - initialization: Optional[ScoringModel]=None, - new_judgments: Optional[dict[str, pd.DataFrame]]=None, + initialization: Optional[ScoringModel] = None, + new_judgments: Optional[dict[str, pd.DataFrame]] = None, ) -> ScoringModel: - """ Learns a scoring model, given user judgments of entities - + """Learns a scoring model, given user judgments of entities + Parameters ---------- user_judgments: dict[str, pd.DataFrame] - May contain different forms of judgments, + May contain different forms of judgments, but most likely will contain "comparisons" and/or "assessments" entities: DataFrame with columns * entity_id: int, index @@ -78,15 +81,15 @@ def user_learn( It is not supposed to affect the output of the training new_judgments: New judgments This allows to prioritize coordinate descent, starting with newly evaluated entities - + Returns ------- model: ScoringModel """ raise NotImplementedError - + def to_json(self): - return (type(self).__name__, ) + return (type(self).__name__,) def __str__(self): return type(self).__name__ diff --git a/solidago/src/solidago/preference_learning/generalized_bradley_terry.py b/solidago/src/solidago/preference_learning/generalized_bradley_terry.py index 3e2cc9596e..7af2b2a55b 100644 --- a/solidago/src/solidago/preference_learning/generalized_bradley_terry.py +++ b/solidago/src/solidago/preference_learning/generalized_bradley_terry.py @@ -18,6 +18,7 @@ def __init__( self, prior_std_dev: float=7, convergence_error: float=1e-5, + high_likelihood_range_threshold = 1.0, ): """ @@ -30,8 +31,10 @@ def __init__( """ self.prior_std_dev = prior_std_dev self.convergence_error = convergence_error - + self.high_likelihood_range_threshold = high_likelihood_range_threshold + @property + @abstractmethod def cumulant_generating_function_derivative(self) -> Callable[[npt.NDArray], npt.NDArray]: """ The beauty of the generalized Bradley-Terry model is that it suffices to specify its cumulant generating function derivative to fully define it, @@ -46,23 +49,33 @@ def cumulant_generating_function_derivative(self) -> Callable[[npt.NDArray], npt ------- out: float """ - raise NotImplementedError - + + @property @abstractmethod - def cumulant_generating_function_second_derivative(self, score_diff: float) -> float: - """ We estimate uncertainty by the flatness of the negative log likelihood, - which is directly given by the second derivative of the cumulant generating function. - - Parameters - ---------- - score_diff: float - Score difference - - Returns - ------- - out: float + def log_likelihood_function(self) -> Callable[[npt.NDArray, npt.NDArray], float]: + """The loss function definition is used only to compute uncertainties. """ - pass + + @cached_property + def translated_negative_log_likelihood(self): + """This function is a convex negative log likelihood, translated such + that its minimum has a constant negative value at `delta=0`. The + roots of this function are used to compute the uncertainties + intervals. If it has only a single root, then uncertainty on the + other side is considered infinite. + """ + ll_function = self.log_likelihood_function + high_likelihood_range_threshold = self.high_likelihood_range_threshold + + @njit + def f(delta, theta_diff, r, coord_indicator, ll_actual): + return ( + ll_function(theta_diff + delta * coord_indicator, r) + - ll_actual + - high_likelihood_range_threshold + ) + + return f @cached_property def update_coordinate_function(self): @@ -101,16 +114,16 @@ def comparison_learning( """ entities = list(set(comparisons["entity_a"]) | set(comparisons["entity_b"])) entity_coordinates = { entity: c for c, entity in enumerate(entities) } - + comparisons_dict = self.comparisons_dict(comparisons, entity_coordinates) - + init_solution = np.zeros(len(entities)) if initialization is not None: for (entity_id, entity_coord) in entity_coordinates.items(): entity_init_values = initialization(entity_id) if entity_init_values is not None: init_solution[entity_coord] = entity_init_values[0] - + updated_coordinates = list() if updated_entities is None else [ entity_coordinates[entity] for entity in updated_entities ] @@ -121,7 +134,7 @@ def get_derivative_args(coord: int, sol: np.ndarray): sol[indices], comparisons_bis ) - + solution = coordinate_descent( self.update_coordinate_function, get_args=get_derivative_args, @@ -129,29 +142,54 @@ def get_derivative_args(coord: int, sol: np.ndarray): updated_coordinates=updated_coordinates, error=self.convergence_error, ) - - uncertainties = [ - self.hessian_diagonal_element(coordinate, solution, comparisons_dict[coordinate][0]) - for coordinate in range(len(entities)) - ] - - model = DirectScoringModel() + + comparisons = comparisons.assign( + entity_a_coord=comparisons["entity_a"].map(entity_coordinates), + entity_b_coord=comparisons['entity_b'].map(entity_coordinates), + ) + score_diff = solution[comparisons["entity_a_coord"]] - solution[comparisons["entity_b_coord"]] + r_actual = (comparisons["comparison"] / comparisons["comparison_max"]).to_numpy() + + uncertainties_left = np.empty_like(solution) + uncertainties_right = np.empty_like(solution) + ll_actual = self.log_likelihood_function(score_diff, r_actual) + for coordinate in range(len(solution)): - model[entities[coordinate]] = solution[coordinate], uncertainties[coordinate] - + comparison_indicator = ( + (comparisons["entity_a_coord"] == coordinate).astype(int) + - (comparisons["entity_b_coord"] == coordinate).astype(int) + ).to_numpy() + try: + uncertainties_left[coordinate] = -1 * njit_brentq( + self.translated_negative_log_likelihood, + args=(score_diff, r_actual, comparison_indicator, ll_actual), + xtol=1e-2, + a=-self.MAX_UNCERTAINTY, + b=0.0, + extend_bounds="no", + ) + except ValueError: + uncertainties_left[coordinate] = self.MAX_UNCERTAINTY + + try: + uncertainties_right[coordinate] = njit_brentq( + self.translated_negative_log_likelihood, + args=(score_diff, r_actual, comparison_indicator, ll_actual), + xtol=1e-2, + a=0.0, + b=self.MAX_UNCERTAINTY, + extend_bounds="no", + ) + except ValueError: + uncertainties_right[coordinate] = self.MAX_UNCERTAINTY + + model = DirectScoringModel() + for coord in range(len(solution)): + model[entities[coord]] = solution[coord], uncertainties_left[coord], uncertainties_right[coord] return model - + def comparisons_dict(self, comparisons, entity_coordinates) -> dict[int, tuple[npt.NDArray, npt.NDArray]]: - comparisons = ( - comparisons[ - ["entity_a","entity_b","comparison", "comparison_max"] - ] - .assign( - pair=comparisons.apply(lambda c: {c["entity_a"], c["entity_b"]}, axis=1) - ) - .drop_duplicates("pair", keep="last") - .drop(columns="pair") - ) + comparisons = comparisons[["entity_a","entity_b","comparison", "comparison_max"]] comparisons_sym = pd.concat( [ comparisons, @@ -176,7 +214,7 @@ def comparisons_dict(self, comparisons, entity_coordinates) -> dict[int, tuple[n coord: (group["entity_b"].to_numpy(), group["comparison"].to_numpy()) for (coord, group) in comparisons_sym.groupby("entity_a") } # type: ignore - + @cached_property def partial_derivative(self): """ Computes the partial derivative along a coordinate, @@ -203,23 +241,9 @@ def njit_partial_derivative( ) ) return njit_partial_derivative - - def hessian_diagonal_element( - self, - coordinate: int, - solution: np.ndarray, - comparisons_indices: np.ndarray, - ) -> float: - """ Computes the second partial derivative """ - result = 1 / self.prior_std_dev ** 2 - for coordinate_bis in comparisons_indices: - score_diff = solution[coordinate] - solution[coordinate_bis] - result += self.cumulant_generating_function_second_derivative(score_diff) - return result class UniformGBT(GeneralizedBradleyTerry): - def __init__( self, prior_std_dev: float = 7, @@ -238,6 +262,21 @@ def __init__( super().__init__(prior_std_dev, convergence_error) self.cumulant_generating_function_error = cumulant_generating_function_error + @cached_property + def log_likelihood_function(self): + @njit + def f(score_diff, r): + score_diff_abs = np.abs(score_diff) + return ( + np.where( + score_diff_abs < 20.0, + np.log(np.sinh(score_diff) / score_diff), + score_diff_abs - np.log(2) - np.log(score_diff_abs) + ) + + r * score_diff + ).sum() + return f + @cached_property def cumulant_generating_function_derivative(self) -> Callable[[npt.NDArray], npt.NDArray]: tolerance = self.cumulant_generating_function_error @@ -252,23 +291,6 @@ def f(score_diff: npt.NDArray): return f - def cumulant_generating_function_second_derivative(self, score_diff: float) -> float: - """We estimate uncertainty by the flatness of the negative log likelihood, - which is directly given by the second derivative of the cumulant generating function. - - Parameters - ---------- - score_diff: float - Score difference - - Returns - ------- - out: float - """ - if np.abs(score_diff) < self.cumulant_generating_function_error: - return (1 / 3) - (score_diff**2 / 15) - return 1 - (1 / np.tanh(score_diff) ** 2) + (1 / score_diff**2) - def to_json(self): return type(self).__name__, dict( prior_std_dev=self.prior_std_dev, diff --git a/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py b/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py index 46111dce2a..fa2518ce4c 100644 --- a/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py +++ b/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py @@ -13,15 +13,16 @@ ) from exc from solidago.scoring_model import ScoringModel, DirectScoringModel +from solidago.solvers import dichotomy from .comparison_learning import ComparisonBasedPreferenceLearning - class LBFGSGeneralizedBradleyTerry(ComparisonBasedPreferenceLearning): def __init__( self, prior_std_dev: float = 7, convergence_error: float = 1e-5, - n_steps: int = 3, + max_iter: int = 100, + high_likelihood_range_threshold = 1.0, ): """ @@ -34,7 +35,8 @@ def __init__( """ self.prior_std_dev = prior_std_dev self.convergence_error = convergence_error - self.n_steps = n_steps + self.max_iter = max_iter + self.high_likelihood_range_threshold = high_likelihood_range_threshold self.device = torch.device("cuda" if torch.cuda.is_available() else "cpu") @abstractmethod @@ -54,22 +56,6 @@ def cumulant_generating_function(self, score_diff: torch.Tensor) -> torch.Tensor """ pass - @abstractmethod - def cumulant_generating_function_second_derivative(self, score_diff: float) -> float: - """We estimate uncertainty by the flatness of the negative log likelihood, - which is directly given by the second derivative of the cumulant generating function. - - Parameters - ---------- - score_diff: float - Score difference - - Returns - ------- - out: float - """ - pass - def comparison_learning( self, comparisons: pd.DataFrame, @@ -94,86 +80,99 @@ def comparison_learning( entities = list(set(comparisons["entity_a"]) | set(comparisons["entity_b"])) entity_coordinates = {entity: c for c, entity in enumerate(entities)} - comparisons = ( - comparisons - .assign( - entity_a=comparisons["entity_a"].map(entity_coordinates), - entity_b=comparisons["entity_b"].map(entity_coordinates), - comparison=comparisons["comparison"] / comparisons["comparison_max"], - pair=comparisons.apply(lambda c: {c["entity_a"], c["entity_b"]}, axis=1), - ) - .drop_duplicates("pair", keep="last") - .drop(columns="pair") + comparisons_np = ( + comparisons["entity_a"].map(entity_coordinates).to_numpy(), + comparisons["entity_b"].map(entity_coordinates).to_numpy(), + (comparisons["comparison"] / comparisons["comparison_max"]).to_numpy() ) solution = torch.normal( - 0, 1, (len(entities),), requires_grad=True, dtype=float, device=self.device + 0, 1, (len(entities),), requires_grad=True, dtype=torch.float32, device=self.device ) - for (entity, coord) in entity_coordinates.items(): - if initialization is not None and entity in initialization: - solution[coord] = initialization[entity] - lbfgs = torch.optim.LBFGS((solution,)) + if initialization is not None: + for (entity_id, values) in initialization.iter_entities(): + entity_coord = entity_coordinates.get(entity_id) + if entity_coord is not None: + score, _left, _right = values + solution[entity_coord] = score + + lbfgs = torch.optim.LBFGS( + (solution,), + max_iter=100, + tolerance_change=self.convergence_error + ) def closure(): lbfgs.zero_grad() - loss = self.loss(solution, comparisons) + loss = self.loss(solution, comparisons_np) loss.backward() return loss - for _step in range(self.n_steps): - lbfgs.step(closure) + lbfgs.step(closure) # type: ignore + solution = solution.detach() - uncertainties = [ - self.hessian_diagonal_element(entity, solution, comparisons) - for entity in range(len(entities)) - ] + def loss_with_delta(delta, comparisons, coord): + solution_with_delta = solution.clone() + solution_with_delta[coord] += delta + return self.loss(solution_with_delta, comparisons, with_regularization=False).item() model = DirectScoringModel() for coordinate in range(len(solution)): + mask = ((comparisons_np[0] == coordinate) | (comparisons_np[1] == coordinate)) + comparisons_np_subset = tuple(arr[mask] for arr in comparisons_np) + ll_solution = self.loss(solution, comparisons_np_subset, with_regularization=False).item() + + try: + uncertainty_left = -1 * dichotomy.solve( + loss_with_delta, + value=ll_solution + self.high_likelihood_range_threshold, + args=(comparisons_np_subset, coordinate), + xmin=-self.MAX_UNCERTAINTY, + xmax=0.0, + error=1e-1, + ) + except ValueError: + uncertainty_left = self.MAX_UNCERTAINTY + + try: + uncertainty_right = dichotomy.solve( + loss_with_delta, + value=ll_solution + self.high_likelihood_range_threshold, + args=(comparisons_np_subset, coordinate), + xmin=0.0, + xmax=self.MAX_UNCERTAINTY, + error=1e-1, + ) + except ValueError: + uncertainty_right = self.MAX_UNCERTAINTY + model[entities[coordinate]] = ( - solution[coordinate].detach().numpy(), - uncertainties[coordinate], + solution[coordinate].item(), + uncertainty_left, + uncertainty_right, ) return model - - def loss(self, solution, comparisons) -> torch.Tensor: - score_diff = ( - solution[comparisons["entity_b"].to_numpy()] - - solution[comparisons["entity_a"].to_numpy()] - ) - return ( - torch.sum(solution**2) / (2 * self.prior_std_dev**2) - + self.cumulant_generating_function(score_diff).sum() - - (score_diff * torch.from_numpy(comparisons["comparison"].to_numpy())).sum() + + def loss(self, solution, comparisons, with_regularization=True): + comp_a, comp_b, comp_value = comparisons + score_diff = solution[comp_b] - solution[comp_a] + loss = ( + self.cumulant_generating_function(score_diff).sum() + - (score_diff * torch.from_numpy(comp_value)).sum() ) - - def hessian_diagonal_element( - self, - entity: int, - solution: torch.Tensor, - comparisons: pd.DataFrame, - ) -> float: - """Computes the second partial derivative""" - result = 1 / self.prior_std_dev**2 - c = comparisons[(comparisons["entity_a"] == entity) | (comparisons["entity_b"] == entity)] - for row in c.itertuples(): - score_diff = ( - solution[row.entity_b] - - solution[row.entity_a] - ) - result += self.cumulant_generating_function_second_derivative(score_diff.detach()) - return result + if with_regularization: + loss += torch.sum(solution**2) / (2 * self.prior_std_dev**2) + return loss class LBFGSUniformGBT(LBFGSGeneralizedBradleyTerry): def __init__( self, prior_std_dev: float = 7, - comparison_max: float = 10, convergence_error: float = 1e-5, cumulant_generating_function_error: float = 1e-5, - n_steps: int = 3, + max_iter: int = 100, ): """ Parameters @@ -183,8 +182,7 @@ def __init__( error: float tolerated error """ - super().__init__(prior_std_dev, convergence_error, n_steps) - self.comparison_max = comparison_max + super().__init__(prior_std_dev, convergence_error, max_iter=max_iter) self.cumulant_generating_function_error = cumulant_generating_function_error def cumulant_generating_function(self, score_diff: torch.Tensor) -> torch.Tensor: @@ -199,49 +197,20 @@ def cumulant_generating_function(self, score_diff: torch.Tensor) -> torch.Tensor ------- out: float """ + score_diff_abs = score_diff.abs() return torch.where( - score_diff != 0, - torch.log(torch.sinh(score_diff) / score_diff), - 0.0 + score_diff_abs > 0, + torch.where( + score_diff_abs < 20.0, + (torch.sinh(score_diff) / score_diff).log(), + score_diff_abs - np.log(2) - score_diff_abs.log(), + ), + 0.0, ) - def cumulant_generating_function_derivative(self, score_diff: float) -> float: - """ For. - - Parameters - ---------- - score_diff: float - Score difference - - Returns - ------- - out: float - """ - if np.abs(score_diff) < self.cumulant_generating_function_error: - return score_diff / 3 - return 1 / np.tanh(score_diff) - 1 / score_diff - - def cumulant_generating_function_second_derivative(self, score_diff: float) -> float: - """We estimate uncertainty by the flatness of the negative log likelihood, - which is directly given by the second derivative of the cumulant generating function. - - Parameters - ---------- - score_diff: float - Score difference - - Returns - ------- - out: float - """ - if np.abs(score_diff) < self.cumulant_generating_function_error: - return (1 / 3) - (score_diff**2 / 15) - return 1 - (1 / np.tanh(score_diff) ** 2) + (1 / score_diff**2) - def to_json(self): return type(self).__name__, dict( prior_std_dev=self.prior_std_dev, - comparison_max=self.comparison_max, convergence_error=self.convergence_error, cumulant_generating_function_error=self.cumulant_generating_function_error, ) @@ -250,9 +219,8 @@ def __str__(self): prop_names = [ "prior_std_dev", "convergence_error", - "comparison_max", "cumulant_generating_function_error", - "n_steps", + "max_iter", ] prop = ", ".join([f"{p}={getattr(self, p)}" for p in prop_names]) return f"{type(self).__name__}({prop})" diff --git a/solidago/src/solidago/solvers/dichotomy.py b/solidago/src/solidago/solvers/dichotomy.py index 1808b58545..a2d0138edb 100644 --- a/solidago/src/solidago/solvers/dichotomy.py +++ b/solidago/src/solidago/solvers/dichotomy.py @@ -4,11 +4,12 @@ def solve( - f: Callable[[float], float], + f: Callable[..., float], value: float = 0, xmin: float = 0, xmax: float = 1, error: float = 1e-6, + args = (), ): """Solves for f(x) == value, using dichotomy search May return an error if f(xmin) * f(xmax) > 0 @@ -29,7 +30,7 @@ def solve( ------- out: float """ - ymin, ymax = f(xmin) - value, f(xmax) - value + ymin, ymax = f(xmin, *args) - value, f(xmax, *args) - value if ymin * ymax > 0: raise ValueError(f"No solution to f(x)={value} was found in [{xmin}, {xmax}]") @@ -40,7 +41,7 @@ def solve( n_iterations = int(np.ceil(np.log2(delta / error))) for _ in range(n_iterations): x = (xmin + xmax) / 2 - y = f(x) - value + y = f(x, *args) - value if y == 0: return x if ymin * y < 0: diff --git a/solidago/src/solidago/solvers/optimize.py b/solidago/src/solidago/solvers/optimize.py index 2e56d048eb..ac31e57c12 100644 --- a/solidago/src/solidago/solvers/optimize.py +++ b/solidago/src/solidago/solvers/optimize.py @@ -6,8 +6,8 @@ Copyright © 2013-2021 Thomas J. Sargent and John Stachurski: BSD-3 All rights reserved. """ -# pylint: skip-file -from typing import Callable, Tuple + +from typing import Callable, Tuple, Literal import numpy as np from numba import njit @@ -41,21 +41,55 @@ def _bisect_interval(a, b, fa, fb) -> Tuple[float, int]: @njit -def njit_brentq(f, args=(), xtol=_xtol, rtol=_rtol, maxiter=_iter, disp=True, a: float=-1.0, b: float=1.0) -> float: - """ `Accelerated brentq. Requires f to be itself jitted via numba. +def njit_brentq( + f, + args=(), + xtol=_xtol, + rtol=_rtol, + maxiter=_iter, + a: float = -1.0, + b: float = 1.0, + extend_bounds: Literal["ascending", "descending", "no"] = "ascending", +) -> float: + """Accelerated brentq. Requires f to be itself jitted via numba. Essentially, numba optimizes the execution by running an optimized compilation of the function when it is first called, and by then running the compiled function. - - + Parameters ---------- f : jitted and callable Python function returning a number. `f` must be continuous. + args : tuple, optional(default=()) + Extra arguments to be used in the function call. + xtol : number, optional(default=2e-12) + The computed root ``x0`` will satisfy ``np.allclose(x, x0, + atol=xtol, rtol=rtol)``, where ``x`` is the exact root. The + parameter must be nonnegative. + rtol : number, optional(default=`4*np.finfo(float).eps`) + The computed root ``x0`` will satisfy ``np.allclose(x, x0, + atol=xtol, rtol=rtol)``, where ``x`` is the exact root. + maxiter : number, optional(default=100) + Maximum number of iterations. + a : number + One end of the bracketing interval [a,b]. + b : number + The other end of the bracketing interval [a,b]. + extend_bounds: default: "ascending", + Whether to extend the interval [a,b] to find a root. + ('no': to keep the bounds [a, b], + 'ascending': extend the bounds assuming `f` is ascending, + 'descending': extend the bounds assuming `f` is descending) """ - while f(a, *args) > 0: - a = a - 2 * (b-a) - while f(b, *args) < 0: - b = b + 2 * (b-a) + if extend_bounds == "ascending": + while f(a, *args) > 0: + a = a - 2 * (b - a) + while f(b, *args) < 0: + b = b + 2 * (b - a) + elif extend_bounds == "descending": + while f(a, *args) < 0: + a = a - 2 * (b - a) + while f(b, *args) > 0: + b = b + 2 * (b - a) if xtol <= 0: raise ValueError("xtol is too small (<= 0)") @@ -134,7 +168,7 @@ def njit_brentq(f, args=(), xtol=_xtol, rtol=_rtol, maxiter=_iter, disp=True, a: fcur = f(xcur, *args) funcalls += 1 - if disp and status == _ECONVERR: + if status == _ECONVERR: raise RuntimeError("Failed to converge") return root # type: ignore @@ -143,13 +177,13 @@ def njit_brentq(f, args=(), xtol=_xtol, rtol=_rtol, maxiter=_iter, disp=True, a: def coordinate_descent( update_coordinate_function: Callable[[Tuple, float], float], get_args: Callable[[int, np.ndarray], Tuple], - initialization: np.ndarray, + initialization: np.ndarray, updated_coordinates: list[int], - error: float = 1e-5 + error: float = 1e-5, ): - """ Minimize a loss function with coordinate descent, + """Minimize a loss function with coordinate descent, by leveraging the partial derivatives of the loss - + Parameters ---------- loss_partial_derivative: callable @@ -160,7 +194,7 @@ def coordinate_descent( Initialization point of the coordinate descent error: float Tolerated error - + Returns ------- out: stationary point of the loss diff --git a/solidago/tests/data/data_1.py b/solidago/tests/data/data_1.py index 6fe97f6e48..29e723f9d2 100644 --- a/solidago/tests/data/data_1.py +++ b/solidago/tests/data/data_1.py @@ -95,24 +95,24 @@ learned_models = { 0: DirectScoringModel({ - 0: (0.8543576022084396, 0.22268338112332428, 0.22268338112332428), - 1: (-0.8542675414366053, 0.22268338112332428, 0.22268338112332428) + 0: (0.8543576022084396, 2.62, 4.52), + 1: (-0.8542675414366053, 4.55, 2.62) }), 1: DirectScoringModel({ - 0: (-0.45987485219302987, 0.3040980645995397, 0.3040980645995397), - 1: (0.46000589337922315, 0.3040980645995397, 0.3040980645995397) + 0: (-0.45987485219302987, 3.39, 2.50), + 1: (0.46000589337922315, 2.50, 3.39) }), 2: DirectScoringModel({ - 0: (-0.6411620404227717, 0.26730021787214453, 0.26730021787214453), - 1: (0.6412670367706607, 0.26730021787214453, 0.26730021787214453) + 0: (-0.6411620404227717, 3.84, 2.53), + 1: (0.6412670367706607, 2.53, 3.84), }), 3: DirectScoringModel({ - 0: (2.0611090358800523, 0.07820614406839796, 0.07820614406839796), - 1: (-2.061088795104216, 0.07820614406839796, 0.07820614406839796) + 0: (2.0611090358800523, 3.73, 11.73), + 1: (-2.061088795104216, 11.73, 3.73), }), 4: DirectScoringModel({ - 0: (-4.949746148097695, 0.030612236968599268, 0.030612236968599268), - 1: (4.949747745198173, 0.030612236968599268, 0.030612236968599268) + 0: (-4.949746148097695, 1000.0, 6.26), + 1: (4.949747745198173, 6.26, 1000.0), }) } diff --git a/solidago/tests/data/data_2.py b/solidago/tests/data/data_2.py index a8974c0930..7d934e6b23 100644 --- a/solidago/tests/data/data_2.py +++ b/solidago/tests/data/data_2.py @@ -96,28 +96,38 @@ }) learned_models = { - 0: DirectScoringModel({ - 1: (1.016590197621329, 0.4638561508964967, 0.4638561508964967), - 2: (-0.7877876012816142, 0.5266102124947752, 0.5266102124947752), - 6: (-0.2291324680780755, 0.5846829229672795, 0.5846829229672795) - }), - 4: DirectScoringModel({ - 1: (-0.29761600676032623, 0.33137621448368626, 0.33137621448368626), - 2: (0.2977647751812212, 0.33137621448368626, 0.33137621448368626) - }), - 2: DirectScoringModel({ - 1: (-4.965658292354929, 0.07061473411881966, 0.07061473411881966), - 2: (0.02121949850814651, 0.06043250186863176, 0.06043250186863176), - 6: (4.944447487603185, 0.030590395515494022, 0.030590395515494022) - }), - 8: DirectScoringModel({ - 1: (0.641162040422771, 0.26729930161403126, 0.26729930161403126), - 6: (-0.6412757158129634, 0.26729930161403126, 0.26729930161403126) - }), - 6: DirectScoringModel({ - 2: (0.6412670367706619, 0.26730021787214453, 0.26730021787214453), - 6: (-0.64116204042277, 0.26730021787214453, 0.26730021787214453) - }) + 0: DirectScoringModel( + { + 1: (1.0166024998812924, 1.86, 2.67), + 2: (-0.7877792323989169, 2.36, 1.84), + 6: (-0.22912573047151286, 2.0, 1.89), + } + ), + 4: DirectScoringModel( + { + 1: (-0.29762516882114326, 3.07, 2.52), + 2: (0.297764775194798, 2.52, 3.07), + } + ), + 2: DirectScoringModel( + { + 1: (-4.965657348164456, 17.7, 3.7), + 2: (0.021224904768728296, 4.57, 10.74), + 6: (4.944450204676572, 6.27, 1000.0), + } + ), + 8: DirectScoringModel( + { + 1: (0.6412670367706624, 2.53, 3.84), + 6: (-0.6411620404227696, 3.84, 2.53), + } + ), + 6: DirectScoringModel( + { + 2: (0.6411620404227699, 2.53, 3.84), + 6: (-0.6412757158129653, 3.84, 2.53), + } + ), } mehestan_scaled_models = { diff --git a/solidago/tests/data/data_3.py b/solidago/tests/data/data_3.py index 77e3e51ad1..584b546446 100644 --- a/solidago/tests/data/data_3.py +++ b/solidago/tests/data/data_3.py @@ -57,7 +57,6 @@ judgments = DataFrameJudgments(pd.DataFrame(dict( # The judgements contain a pair of entities (2, 3) compared twice by user 1 - # The learned models assume than only the last one is considered in the learning process. user_id= [0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 3, 3, 3, 3, 3, 3, 4, 4, 4], entity_a=[0, 2, 4, 0, 1, 4, 3, 4, 0, 2, 1, 0, 4, 0, 0, 0, 0, 2, 4, 0, 1, 2], entity_b=[2, 3, 3, 2, 2, 3, 2, 1, 4, 3, 0, 4, 1, 1, 2, 3, 4, 3, 3, 1, 2, 3], @@ -127,37 +126,47 @@ }) learned_models = { - 0: DirectScoringModel({ - 0: (5.033398104293532, 0.03096002542681708, 0.03096002542681708), - 2: (-4.70158398961812, 0.16736063832963174, 0.16736063832963174), - 3: (-2.2520973379065916, 0.21329795423450776, 0.21329795423450776), - 4: (1.920367641692402, 0.07689734133169311, 0.07689734133169311) - }), - 1: DirectScoringModel({ - 0: (-0.2647961020419981, 0.4346751126408668, 0.4346751126408668), - 1: (-0.723484071079214, 0.4387030807322584, 0.4387030807322584), - 2: (-1.9388463288575928, 0.5587891835427051, 0.5587891835427051), - 3: (1.5444643844051669, 0.4306522267140735, 0.4306522267140735), - 4: (1.383154679686365, 0.7248330732791874, 0.7248330732791874) - }), - 2: DirectScoringModel({ - 0: (-0.41403365335425246, 0.6232992917299677, 0.6232992917299677), - 1: (0.6217382466724807, 0.5848617488561728, 0.5848617488561728), - 4: (-0.2073399097731925, 0.6429793786107434, 0.6429793786107434) - }), - 3: DirectScoringModel({ - 0: (-0.7616735994053457, 1.136102459884623, 1.136102459884623), - 1: (-0.1376152832458718, 0.32929993602267005, 0.32929993602267005), - 2: (0.26367367798854036, 0.6238313087499935, 0.6238313087499935), - 3: (0.03904465536134892, 0.958527480363601, 0.958527480363601), - 4: (0.5972697855709417, 0.5727475480020277, 0.5727475480020277) - }), - 4: DirectScoringModel({ - 0: (-0.374212225919361, 0.2649557275176049, 0.2649557275176049), - 1: (0.9304055634303148, 0.5771139502767695, 0.5771139502767695), - 2: (0.351959458123829, 0.5817762983596343, 0.5817762983596343), - 3: (-0.9084921677179439, 0.2696180756004697, 0.2696180756004697) - }) + 0: DirectScoringModel( + { + 0: (5.033397746231767, 6.16, 1000.0), + 2: (-4.701576351427851, 7.36, 2.57), + 3: (-2.2520977680270975, 2.93, 3.04), + 4: (1.920367325723618, 3.78, 11.67), + } + ), + 1: DirectScoringModel( + { + 0: (0.1240287651798575, 1.91, 1.92), + 1: (-0.20925013140334098, 1.99, 1.88), + 2: (-0.7121177373667139, 1.38, 1.23), + 3: (-0.17088798816552617, 1.53, 1.54), + 4: (0.968692135233115, 1.45, 1.90), + } + ), + 2: DirectScoringModel( + { + 0: (-0.41427772976495064, 2.05, 1.77), + 1: (0.6215002710681929, 1.76, 2.22), + 4: (-0.20758081689489893, 1.93, 1.81), + } + ), + 3: DirectScoringModel( + { + 0: (-0.7616827423140184, 1.49, 1.26), + 1: (-0.13763264866961566, 2.55, 3.04), + 2: (0.2636628216175936, 1.78, 2.04), + 3: (0.03903170049617262, 1.5, 1.5), + 4: (0.5972590006109219, 1.79, 2.23), + } + ), + 4: DirectScoringModel( + { + 0: (-0.37421638528904544, 3.82, 2.55), + 1: (0.9304020798748038, 1.77, 2.24), + 2: (0.3519562762627696, 1.89, 2.01), + 3: (-0.9084875543437719, 3.86, 2.51), + } + ), } mehestan_scaled_models = { diff --git a/solidago/tests/test_solvers.py b/solidago/tests/test_solvers.py index 8aa9982fbb..2a841aaaea 100644 --- a/solidago/tests/test_solvers.py +++ b/solidago/tests/test_solvers.py @@ -1,7 +1,35 @@ import pytest +from numba import njit + +from solidago.solvers.dichotomy import solve as dichotomy_solve +from solidago.solvers.optimize import njit_brentq as brentq -from solidago.solvers.dichotomy import solve def test_dichotomy(): - assert solve(lambda t: t, 1, 0, 3) == pytest.approx(1, abs=1e-4) - assert solve(lambda t: 2 - t**2, 1, 0, 3) == pytest.approx(1, abs=1e-4) + assert dichotomy_solve(lambda t: t, 1, 0, 3) == pytest.approx(1, abs=1e-4) + assert dichotomy_solve(lambda t: 2 - t**2, 1, 0, 3) == pytest.approx(1, abs=1e-4) + + +def test_brentq_fails_to_converge_for_non_zero_method(): + @njit + def one_plus_x_square(x): + return 1 + x * x + + with pytest.raises(ValueError): + brentq(one_plus_x_square, extend_bounds="no") + + +def test_brentq_finds_zeros_of_simple_increasing_linear(): + @njit + def x_plus_five(x): + return x + 5 + + assert brentq(x_plus_five, extend_bounds="ascending") == -5.0 + + +def test_brentq_finds_zeros_of_simple_decreasing_linear(): + @njit + def minus_x_plus_twelve(x): + return -x + 12 + + assert brentq(minus_x_plus_twelve, extend_bounds="descending") == 12.0 From c58e4248ecc52112d8854214357c176f847ba50d Mon Sep 17 00:00:00 2001 From: Adrien Matissart Date: Thu, 2 May 2024 11:28:03 +0200 Subject: [PATCH 2/5] cleanup docstrings in Solidago (wip) --- solidago/src/solidago/pipeline/__init__.py | 2 ++ solidago/src/solidago/pipeline/pipeline.py | 8 ++--- .../solidago/preference_learning/__init__.py | 5 ++- .../src/solidago/preference_learning/base.py | 12 ++++--- .../generalized_bradley_terry.py | 6 +--- .../lbfgs_generalized_bradley_terry.py | 6 +--- .../solidago/trust_propagation/__init__.py | 4 ++- .../src/solidago/trust_propagation/base.py | 17 ++++++++-- .../trust_propagation/lipschitrust.py | 34 ++++++------------- .../trust_propagation/no_trust_propagation.py | 27 ++++----------- .../solidago/trust_propagation/trust_all.py | 32 +++-------------- .../src/solidago/voting_rights/__init__.py | 5 ++- .../voting_rights/affine_overtrust.py | 7 ++-- 13 files changed, 65 insertions(+), 100 deletions(-) diff --git a/solidago/src/solidago/pipeline/__init__.py b/solidago/src/solidago/pipeline/__init__.py index 57bb379f53..fa97097024 100644 --- a/solidago/src/solidago/pipeline/__init__.py +++ b/solidago/src/solidago/pipeline/__init__.py @@ -1,3 +1,5 @@ from .inputs import TournesolInput from .outputs import PipelineOutput from .pipeline import DefaultPipeline, Pipeline + +__all__ = ["TournesolInput", "DefaultPipeline", "Pipeline", "PipelineOutput"] diff --git a/solidago/src/solidago/pipeline/pipeline.py b/solidago/src/solidago/pipeline/pipeline.py index 4e99519c57..037d1b138d 100644 --- a/solidago/src/solidago/pipeline/pipeline.py +++ b/solidago/src/solidago/pipeline/pipeline.py @@ -82,7 +82,7 @@ def __init__( aggregation: Aggregation = DefaultPipeline.aggregation, post_process: PostProcess = DefaultPipeline.post_process, ): - """ Instantiates the pipeline components. + """Instantiates the pipeline components. Parameters ---------- @@ -148,8 +148,6 @@ def __call__( judgments[user] must yield the judgment data provided by the user init_user_models: dict[int, ScoringModel] user_models[user] is the user's model - skip_set: set[int] - Steps that are skipped in the pipeline Returns ------- @@ -229,8 +227,8 @@ def to_json(self): post_process=self.post_process.to_json() ) + @staticmethod def save_individual_scalings( - self, user_models: dict[int, ScaledScoringModel], output: PipelineOutput, ): @@ -251,8 +249,8 @@ def save_individual_scalings( ) output.save_individual_scalings(scalings_df) + @staticmethod def save_individual_scores( - self, user_scorings: dict[int, ScoringModel], raw_user_scorings: dict[int, ScoringModel], voting_rights: VotingRights, diff --git a/solidago/src/solidago/preference_learning/__init__.py b/solidago/src/solidago/preference_learning/__init__.py index da46034788..d1776ea8a7 100644 --- a/solidago/src/solidago/preference_learning/__init__.py +++ b/solidago/src/solidago/preference_learning/__init__.py @@ -1,4 +1,4 @@ -""" Step 3 of the pipeline. +""" **Step 3 of the pipeline** Preference learning infers, for each user and based on their data, a model of the user's preferences. @@ -13,3 +13,6 @@ from .lbfgs_generalized_bradley_terry import LBFGSUniformGBT except RuntimeError: pass + + +__all__ = ["PreferenceLearning", "UniformGBT", "LBFGSUniformGBT"] diff --git a/solidago/src/solidago/preference_learning/base.py b/solidago/src/solidago/preference_learning/base.py index fe998b5509..3547fe3135 100644 --- a/solidago/src/solidago/preference_learning/base.py +++ b/solidago/src/solidago/preference_learning/base.py @@ -26,7 +26,7 @@ def __call__( Parameters ---------- - user_judgments: dict[str, pd.DataFrame] + judgments: May contain different forms of judgments, but most likely will contain "comparisons" and/or "assessments" entities: DataFrame with columns @@ -35,8 +35,9 @@ def __call__( initialization: dict[int, ScoringModel] or ScoringModel or None Starting models, added to facilitate optimization It is not supposed to affect the output of the training - new_judgments: New judgments - This allows to prioritize coordinate descent, starting with newly evaluated entities + new_judgments: + New judgments + This allows to prioritize coordinate descent, starting with newly evaluated entities Returns ------- @@ -79,8 +80,9 @@ def user_learn( initialization: ScoringModel or None Starting model, added to facilitate optimization It is not supposed to affect the output of the training - new_judgments: New judgments - This allows to prioritize coordinate descent, starting with newly evaluated entities + new_judgments: + New judgments + This allows to prioritize coordinate descent, starting with newly evaluated entities Returns ------- diff --git a/solidago/src/solidago/preference_learning/generalized_bradley_terry.py b/solidago/src/solidago/preference_learning/generalized_bradley_terry.py index 7af2b2a55b..f1a3afae0e 100644 --- a/solidago/src/solidago/preference_learning/generalized_bradley_terry.py +++ b/solidago/src/solidago/preference_learning/generalized_bradley_terry.py @@ -252,12 +252,8 @@ def __init__( ): """ - Parameters + Parameters (TODO) ---------- - initialization: dict[int, float] - previously computed entity scores - error: float - tolerated error """ super().__init__(prior_std_dev, convergence_error) self.cumulant_generating_function_error = cumulant_generating_function_error diff --git a/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py b/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py index fa2518ce4c..4de2738d11 100644 --- a/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py +++ b/solidago/src/solidago/preference_learning/lbfgs_generalized_bradley_terry.py @@ -175,12 +175,8 @@ def __init__( max_iter: int = 100, ): """ - Parameters + Parameters (TODO) ---------- - initialization: dict[int, float] - previously computed entity scores - error: float - tolerated error """ super().__init__(prior_std_dev, convergence_error, max_iter=max_iter) self.cumulant_generating_function_error = cumulant_generating_function_error diff --git a/solidago/src/solidago/trust_propagation/__init__.py b/solidago/src/solidago/trust_propagation/__init__.py index 3a0e3908d2..f7814548d2 100644 --- a/solidago/src/solidago/trust_propagation/__init__.py +++ b/solidago/src/solidago/trust_propagation/__init__.py @@ -1,4 +1,4 @@ -""" Step 1 of the pipeline. +""" **Step 1 in the pipeline** Trust propagation is tasked to combine pretrusts and vouches to derive trust scores for the different users. @@ -8,3 +8,5 @@ from .no_trust_propagation import NoTrustPropagation from .lipschitrust import LipschiTrust from .trust_all import TrustAll + +__all__ = ["TrustPropagation", "NoTrustPropagation", "LipschiTrust", "TrustAll"] diff --git a/solidago/src/solidago/trust_propagation/base.py b/solidago/src/solidago/trust_propagation/base.py index 1510283dff..a9c40a94dc 100644 --- a/solidago/src/solidago/trust_propagation/base.py +++ b/solidago/src/solidago/trust_propagation/base.py @@ -3,6 +3,10 @@ import pandas as pd class TrustPropagation(ABC): + """ + Base class for Trust Propagation algorithms + """ + @abstractmethod def __call__(self, users: pd.DataFrame, @@ -12,17 +16,24 @@ def __call__(self, Parameters ---------- - users: DataFrame with columns + users: DataFrame + with columns + * user_id (int, index) * is_pretrusted (bool) - vouches: DataFrame with columns + + vouches: DataFrame + with columns + * voucher (str) * vouchee (str) * vouch (float) Returns ------- - users: DataFrame with columns + users: DataFrame + with columns + * user_id (int, index) * is_pretrusted (bool) * trust_score (float) diff --git a/solidago/src/solidago/trust_propagation/lipschitrust.py b/solidago/src/solidago/trust_propagation/lipschitrust.py index 0373cd3927..92615cfacc 100644 --- a/solidago/src/solidago/trust_propagation/lipschitrust.py +++ b/solidago/src/solidago/trust_propagation/lipschitrust.py @@ -17,14 +17,18 @@ def __init__(self, error: float=1e-8 ): """ A robustified variant of PageRank - Inputs: - - pretrust_value is the pretrust of a pretrusted user - (Trust^{pre}_{checkmark} in paper) - - decay is the decay of trusts in voucher's vouchees - (beta in paper) - - sink_vouch is the vouch to none, used to incentivize vouching + + Parameters + ---------- + pretrust_value: + the pretrust of a pretrusted user. + (`Trust^{pre}_{checkmark}` in paper) + decay: + the decay of trusts in voucher's vouchees. + (`beta` in paper) + sink_vouch: is the vouch to none, used to incentivize vouching (V^{sink}_{checkmark} in paper) - - error > 0 is an upper bound on error (in L1 norm) + error: > 0 is an upper bound on error (in L1 norm) (epsilon_{LipschiTrust} in paper) """ assert pretrust_value >= 0 and pretrust_value <= 1 @@ -41,22 +45,6 @@ def __call__(self, users: pd.DataFrame, vouches: pd.DataFrame ) -> pd.DataFrame: - """ - Inputs: - - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - - vouches: DataFrame with columns - * voucher (str) - * vouchee (str) - * vouch (float) - - Returns: - - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - * trust_score (float) - """ if len(users) == 0: return users.assign(trust_score=[]) diff --git a/solidago/src/solidago/trust_propagation/no_trust_propagation.py b/solidago/src/solidago/trust_propagation/no_trust_propagation.py index 43a11bf575..9c81ab7282 100644 --- a/solidago/src/solidago/trust_propagation/no_trust_propagation.py +++ b/solidago/src/solidago/trust_propagation/no_trust_propagation.py @@ -5,32 +5,19 @@ class NoTrustPropagation(TrustPropagation): def __init__(self, pretrust_value: float=0.8,): + """ + Parameters + ---------- + pretrust_value: + trust score to assign to pretrusted users + """ self.pretrust_value = pretrust_value def __call__(self, users: pd.DataFrame, vouches: pd.DataFrame ) -> pd.DataFrame: - """ Propagates trust through vouch network - - Parameters - ---------- - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - vouches: DataFrame with columns - * voucher (str) - * vouchee (str) - * vouch (float) - - Returns - ------- - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - * trust_score (float) - """ - return users.assign(trust_score=users["is_pretrusted"] * pretrust_value) + return users.assign(trust_score=users["is_pretrusted"] * self.pretrust_value) def __str__(self): return f"{type(self).__name__}(pretrust_value={self.pretrust_value})" diff --git a/solidago/src/solidago/trust_propagation/trust_all.py b/solidago/src/solidago/trust_propagation/trust_all.py index 53f59e204b..79f3d2e37b 100644 --- a/solidago/src/solidago/trust_propagation/trust_all.py +++ b/solidago/src/solidago/trust_propagation/trust_all.py @@ -1,32 +1,10 @@ -""" TrustAll is a naive solution that assignes an equal amount of trust to all users -""" - from .base import TrustPropagation import pandas as pd -import numpy as np + class TrustAll(TrustPropagation): - def __call__(self, - users: pd.DataFrame, - vouches: pd.DataFrame - ) -> dict[str, float]: - """ - Inputs: - - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - - vouches: DataFrame with columns - * voucher (str) - * vouchee (str) - * vouch (float) - - Returns: - - users: DataFrame with columns - * user_id (int, index) - * is_pretrusted (bool) - * trust_score (float) - """ - return users.assign(trust_score=[1.0] * len(users)) - - + """`TrustAll` is a naive solution that assignes an equal amount of trust to all users""" + + def __call__(self, users: pd.DataFrame, vouches: pd.DataFrame): + return users.assign(trust_score=1.0) diff --git a/solidago/src/solidago/voting_rights/__init__.py b/solidago/src/solidago/voting_rights/__init__.py index 5587816df3..2504129452 100644 --- a/solidago/src/solidago/voting_rights/__init__.py +++ b/solidago/src/solidago/voting_rights/__init__.py @@ -1,4 +1,4 @@ -""" Step 2 of the pipeline. +""" **Step 2 in the pipeline** Voting rights are assigned per user and per entity, based on users' trust scores and privacy settings. @@ -11,3 +11,6 @@ from .affine_overtrust import AffineOvertrust from .compute_voting_rights import compute_voting_rights + + +__all__ = ["VotingRightsAssignment", "IsTrust", "AffineOvertrust"] diff --git a/solidago/src/solidago/voting_rights/affine_overtrust.py b/solidago/src/solidago/voting_rights/affine_overtrust.py index 8732c4df9a..1096f3fff8 100644 --- a/solidago/src/solidago/voting_rights/affine_overtrust.py +++ b/solidago/src/solidago/voting_rights/affine_overtrust.py @@ -178,11 +178,10 @@ def min_voting_right( ---------- max_overtrust: float Maximal overtrust allowed for entity_id - users: DataFrame with columns - * user_id (int, index) - * trust_score (float) + trust_scores: + trust score values per user privacy_weights: dict[int, float] - privacy_weights[u] is the privacy weight of user u + privacy weight per user Returns ------- From 5e6d598be6ee62f23104f97477fa77dd0a64e733 Mon Sep 17 00:00:00 2001 From: Adrien Matissart Date: Mon, 13 May 2024 01:17:19 +0200 Subject: [PATCH 3/5] implement 'get_pipeline_kwargs' in TournesolInput --- solidago/src/solidago/pipeline/inputs.py | 74 ++++++++++++++-------- solidago/src/solidago/pipeline/pipeline.py | 18 +++++- solidago/tests/test_judgments.py | 7 -- solidago/tests/test_privacy_settings.py | 4 +- 4 files changed, 66 insertions(+), 37 deletions(-) delete mode 100644 solidago/tests/test_judgments.py diff --git a/solidago/src/solidago/pipeline/inputs.py b/solidago/src/solidago/pipeline/inputs.py index 9e880f5c2a..fdfcd0703b 100644 --- a/solidago/src/solidago/pipeline/inputs.py +++ b/solidago/src/solidago/pipeline/inputs.py @@ -57,6 +57,46 @@ def get_individual_scores( ) -> Optional[pd.DataFrame]: raise NotImplementedError + def get_vouches(self): + # TODO: make abstract and implement in subclasses + return pd.DataFrame(columns=["voucher", "vouchee", "vouch"]) + + def get_pipeline_kwargs(self, criterion: str): + ratings_properties = self.ratings_properties + users = ratings_properties.groupby("user_id").first()[["trust_score"]] + users["is_pretrusted"] = users["trust_score"] >= 0.8 + vouches = self.get_vouches() + comparisons = self.get_comparisons(criteria=criterion) + entities_ids = set(comparisons["entity_a"].unique()) | set( + comparisons["entity_b"].unique() + ) + entities = pd.DataFrame(index=list(entities_ids)) + + privacy = PrivacySettings() + user_entity_pairs = set( + comparisons[["user_id", "entity_a"]].itertuples(index=False, name=None) + ).union(comparisons[["user_id", "entity_b"]].itertuples(index=False, name=None)) + for rating in ratings_properties.itertuples(): + if (rating.user_id, rating.entity_id) in user_entity_pairs: + privacy[(rating.user_id, rating.entity_id)] = not rating.is_public + + judgments = DataFrameJudgments( + comparisons=comparisons.rename( + columns={ + "score": "comparison", + "score_max": "comparison_max", + } + ) + ) + + return { + "users": users, + "vouches": vouches, + "entities": entities, + "privacy": privacy, + "judgments": judgments, + } + class TournesolInputFromPublicDataset(TournesolInput): def __init__(self, dataset_zip: Union[str, BinaryIO]): @@ -72,14 +112,18 @@ def __init__(self, dataset_zip: Union[str, BinaryIO]): self.comparisons = pd.read_csv(comparison_file, keep_default_na=False) self.entity_id_to_video_id = pd.Series( list(set(self.comparisons.video_a) | set(self.comparisons.video_b)), - name="video_id" + name="video_id", ) video_id_to_entity_id = { video_id: entity_id for (entity_id, video_id) in self.entity_id_to_video_id.items() } - self.comparisons["entity_a"] = self.comparisons["video_a"].map(video_id_to_entity_id) - self.comparisons["entity_b"] = self.comparisons["video_b"].map(video_id_to_entity_id) + self.comparisons["entity_a"] = self.comparisons["video_a"].map( + video_id_to_entity_id + ) + self.comparisons["entity_b"] = self.comparisons["video_b"].map( + video_id_to_entity_id + ) self.comparisons.drop(columns=["video_a", "video_b"], inplace=True) with (zipfile.Path(zip_file) / "users.csv").open(mode="rb") as users_file: @@ -153,27 +197,3 @@ def get_individual_scores( ) -> Optional[pd.DataFrame]: # TODO: read contributor scores from individual_scores.csv return None - - def get_pipeline_objects(self): - users = self.users - users = users.assign(is_pretrusted=(users["trust_score"] >= 0.8)) - vouches = pd.DataFrame(columns=["voucher", "vouchee", "vouch"]) - entities_indices = set(self.comparisons["entity_a"]) | set(self.comparisons["entity_b"]) - entities = pd.DataFrame(index=list(entities_indices)) - entities.index.name = "entity_id" - privacy = PrivacySettings() - for (user_id, entity_id) in set( - self.comparisons[["user_id", "entity_a"]].itertuples(index=False, name=None) - ).union( - self.comparisons[["user_id", "entity_b"]].itertuples(index=False, name=None) - ): - privacy[user_id, entity_id] = False - return users, vouches, entities, privacy - - def get_judgments(self, criterion): - comparisons = self.comparisons - if criterion is not None: - comparisons = comparisons[comparisons["criteria"] == criterion] - comparisons = comparisons.rename(columns={"score": "comparison"}) - comparisons = comparisons.assign(comparison_max=[10] * len(comparisons)) - return DataFrameJudgments(comparisons=comparisons) diff --git a/solidago/src/solidago/pipeline/pipeline.py b/solidago/src/solidago/pipeline/pipeline.py index 037d1b138d..e837d8c776 100644 --- a/solidago/src/solidago/pipeline/pipeline.py +++ b/solidago/src/solidago/pipeline/pipeline.py @@ -16,6 +16,7 @@ from solidago.aggregation import Aggregation, StandardizedQrMedian, StandardizedQrQuantile, Average, EntitywiseQrQuantile from solidago.post_process import PostProcess, Squash, NoPostProcess +from solidago.pipeline.inputs import TournesolInput from solidago.pipeline.outputs import PipelineOutput logger = logging.getLogger(__name__) @@ -118,7 +119,22 @@ def from_json(cls, json) -> "Pipeline": aggregation=aggregation_from_json(json["aggregation"]), post_process=post_process_from_json(json["post_process"]), ) - + + def run( + self, + input: TournesolInput, + criterion: str, + output: Optional[PipelineOutput] = None + ): + # TODO: criterion should be managed by TournesolInput + + # TODO: read existing individual scores from input + # to pass `init_user_models` + return self( + **input.get_pipeline_kwargs(criterion), + output=output, + ) + def __call__( self, users: pd.DataFrame, diff --git a/solidago/tests/test_judgments.py b/solidago/tests/test_judgments.py deleted file mode 100644 index ffa05c9403..0000000000 --- a/solidago/tests/test_judgments.py +++ /dev/null @@ -1,7 +0,0 @@ -from solidago.pipeline.inputs import TournesolInputFromPublicDataset -from solidago.judgments import Judgments, DataFrameJudgments - -def test_tournesol_import(): - inputs = TournesolInputFromPublicDataset("tests/data/tiny_tournesol.zip") - judgments = inputs.get_judgments("largely_recommended") - assert "aidjango" in set(judgments.comparisons["public_username"]) diff --git a/solidago/tests/test_privacy_settings.py b/solidago/tests/test_privacy_settings.py index 28d828388e..b92cffb441 100644 --- a/solidago/tests/test_privacy_settings.py +++ b/solidago/tests/test_privacy_settings.py @@ -11,10 +11,10 @@ def test_privacy_io(): def test_tournesol_import(): inputs = TournesolInputFromPublicDataset("tests/data/tiny_tournesol.zip") - privacy = inputs.get_pipeline_objects()[3] + privacy = inputs.get_pipeline_kwargs(criterion="largely_recommended")["privacy"] aidjango_id = inputs.users[inputs.users["public_username"] == "aidjango"].index[0] video_id_to_entity_id = { video_id: entity_id for (entity_id, video_id) in inputs.entity_id_to_video_id.items() } - assert not privacy[aidjango_id, video_id_to_entity_id['dBap_Lp-0oc']] + assert privacy[aidjango_id, video_id_to_entity_id['dBap_Lp-0oc']] == False From 051f0884366d6fff48d94b1bc495ddf7901a4c02 Mon Sep 17 00:00:00 2001 From: Adrien Matissart Date: Thu, 16 May 2024 16:45:52 +0200 Subject: [PATCH 4/5] fix experiments script --- solidago/experiments/tournesol.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/solidago/experiments/tournesol.py b/solidago/experiments/tournesol.py index 6d46f016bd..9317a5c41c 100644 --- a/solidago/experiments/tournesol.py +++ b/solidago/experiments/tournesol.py @@ -38,8 +38,6 @@ for entity_id, video_id in enumerate(inputs.entity_id_to_video_id) } -logger.info("Preprocessing data for the pipeline") -users, vouches, all_entities, privacy = inputs.get_pipeline_objects() # criteria = set(inputs.comparisons["criteria"]) criteria = { "largely_recommended" } @@ -89,10 +87,18 @@ user_outputs, entities, voting_rights, scaled_user_models = dict(), dict(), dict(), dict() -users = pipeline.trust_propagation(users, vouches) for c in criteria: logger.info(f"Running the pipeline for criterion `{c}`") + + pipeline_objects = inputs.get_pipeline_kwargs(criterion=c) + users = pipeline_objects["users"] + vouches = pipeline_objects["vouches"] + all_entities = pipeline_objects["entities"] + privacy = pipeline_objects["privacy"] + judgments = pipeline_objects["judgements"] + + users = pipeline.trust_propagation(users, vouches) judgments = inputs.get_judgments(c) From 34836092c8ab60bb27ba84292ebd5235ea0ce2b4 Mon Sep 17 00:00:00 2001 From: Adrien Matissart Date: Thu, 16 May 2024 17:24:18 +0200 Subject: [PATCH 5/5] read vouches in TournesolInput --- backend/ml/inputs.py | 12 +++++++ backend/tournesol/lib/public_dataset.py | 10 +++--- solidago/src/solidago/pipeline/inputs.py | 44 ++++++++++++++++++------ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/backend/ml/inputs.py b/backend/ml/inputs.py index 9f8272378f..52bfd6003b 100644 --- a/backend/ml/inputs.py +++ b/backend/ml/inputs.py @@ -14,6 +14,7 @@ ContributorScaling, Entity, ) +from vouch.models import Voucher class MlInputFromDb(TournesolInput): @@ -189,3 +190,14 @@ def get_individual_scores( dtf = pd.DataFrame(values) return dtf[["user_id", "entity", "criteria", "raw_score"]] + + def get_vouches(self): + values = Voucher.objects.filter( + by__is_active=True, + to__is_active=True, + ).values( + voucher="by__id", + vouchee="to__id", + vouch="value", + ) + return pd.DataFrame(values) diff --git a/backend/tournesol/lib/public_dataset.py b/backend/tournesol/lib/public_dataset.py index 8612436d52..58d8e3ef16 100644 --- a/backend/tournesol/lib/public_dataset.py +++ b/backend/tournesol/lib/public_dataset.py @@ -291,7 +291,7 @@ def write_comparisons_file( "criteria", "score", "score_max", - "week_date" + "week_date", ] writer = csv.DictWriter(write_target, fieldnames=fieldnames) writer.writeheader() @@ -413,7 +413,9 @@ def write_vouchers_file(write_target): "to_username": voucher.to.username, "value": voucher.value, } - for voucher in Voucher.objects.filter(is_public=True) - .select_related("by", "to") - .order_by("by__username", "to__username") + for voucher in ( + Voucher.objects.filter(is_public=True, by__is_active=True, to__is_active=True) + .select_related("by", "to") + .order_by("by__username", "to__username") + ) ) diff --git a/solidago/src/solidago/pipeline/inputs.py b/solidago/src/solidago/pipeline/inputs.py index fdfcd0703b..8109f8394c 100644 --- a/solidago/src/solidago/pipeline/inputs.py +++ b/solidago/src/solidago/pipeline/inputs.py @@ -57,14 +57,26 @@ def get_individual_scores( ) -> Optional[pd.DataFrame]: raise NotImplementedError + @abstractmethod def get_vouches(self): - # TODO: make abstract and implement in subclasses - return pd.DataFrame(columns=["voucher", "vouchee", "vouch"]) + """Fetch data about vouches shared between users + + Returns: + - DataFrame with columns + * `voucher`: int, user_id of the user who gives the vouch + * `vouchee`: int, user_id of the user who receives the vouch + * `vouch`: float, value of this vouch + """ + raise NotImplementedError + + def get_users(self): + users = self.ratings_properties.groupby("user_id").first()[["trust_score"]] + users["is_pretrusted"] = users["trust_score"] >= 0.8 + return users def get_pipeline_kwargs(self, criterion: str): ratings_properties = self.ratings_properties - users = ratings_properties.groupby("user_id").first()[["trust_score"]] - users["is_pretrusted"] = users["trust_score"] >= 0.8 + users = self.get_users() vouches = self.get_vouches() comparisons = self.get_comparisons(criteria=criterion) entities_ids = set(comparisons["entity_a"].unique()) | set( @@ -134,26 +146,25 @@ def __init__(self, dataset_zip: Union[str, BinaryIO]): # Fill trust_score on newly created users for which it was not computed yet self.users.trust_score = pd.to_numeric(self.users.trust_score).fillna(0.0) - username_to_user_id = pd.Series( + self.username_to_user_id = pd.Series( data=self.users.index, index=self.users["public_username"] ) - self.comparisons = self.comparisons.join(username_to_user_id, on="public_username") - + self.comparisons = self.comparisons.join(self.username_to_user_id, on="public_username") + with (zipfile.Path(zip_file) / "vouchers.csv").open(mode="rb") as vouchers_file: # keep_default_na=False is required otherwise some public usernames # such as "NA" are converted to float NaN. self.vouchers = pd.read_csv(vouchers_file, keep_default_na=False) - + with (zipfile.Path(zip_file) / "collective_criteria_scores.csv").open(mode="rb") as collective_scores_file: # keep_default_na=False is required otherwise some public usernames # such as "NA" are converted to float NaN. self.collective_scores = pd.read_csv(collective_scores_file, keep_default_na=False) - + with (zipfile.Path(zip_file) / "individual_criteria_scores.csv").open(mode="rb") as individual_scores_file: # keep_default_na=False is required otherwise some public usernames # such as "NA" are converted to float NaN. self.individual_scores = pd.read_csv(individual_scores_file, keep_default_na=False) - @classmethod def download(cls) -> "TournesolInputFromPublicDataset": @@ -197,3 +208,16 @@ def get_individual_scores( ) -> Optional[pd.DataFrame]: # TODO: read contributor scores from individual_scores.csv return None + + def get_vouches(self): + vouchers = self.vouchers[ + self.vouchers.by_username.isin(self.username_to_user_id.index) + & self.vouchers.to_username.isin(self.username_to_user_id.index) + ] + return pd.DataFrame( + { + "voucher": vouchers.by_username.map(self.username_to_user_id), + "vouchee": vouchers.to_username.map(self.username_to_user_id), + "vouch": vouchers.value, + } + )