Merge pull request #324 from ornicar/ScalaEvaluator

Scala evaluator
pull/328/head
Thibault Duplessis 2015-02-22 03:03:05 +01:00
commit 4f1fbabca1
13 changed files with 279 additions and 544 deletions

View File

@ -11,7 +11,7 @@ import lila.analyse.{ Analysis, TimeChart, AdvantageChart, Accuracy }
import lila.api.Context
import lila.app._
import lila.common.HTTPRequest
import lila.evaluation.GameResults
import lila.evaluation.GameAssessments
import lila.game.{ Pov, Game => GameModel, GameRepo, PgnDump }
import lila.hub.actorApi.map.Tell
import lila.round.actorApi.AnalysisAvailable
@ -66,8 +66,8 @@ object Analyse extends LilaController {
if (HTTPRequest.isBot(ctx.req)) divider.empty
else divider(pov.game, initialFen)
val pgn = Env.game.pgnDump(pov.game, initialFen)
val assessResults = if (isGranted(_.MarkEngine)) Env.mod.assessApi.getResultsByGameId(pov.game.id)
else fuccess(GameResults(None, None))
val assessResults = if (isGranted(_.MarkEngine)) Env.mod.assessApi.getGameResultsById(pov.game.id)
else fuccess(GameAssessments(None, None))
assessResults flatMap {
results =>
Env.api.roundApi.watcher(pov, lila.api.Mobile.Api.currentVersion,

View File

@ -86,28 +86,6 @@ object Mod extends LilaController {
def redirect(username: String, mod: Boolean = true) = Redirect(routes.User.show(username).url + mod.??("?mod"))
def assessGame(id: String, side: String) = SecureBody(_.MarkEngine) { implicit ctx =>
me =>
import play.api.data.Forms._
import play.api.data._
implicit def req = ctx.body
Form(single("assessment" -> number(min = 1, max = 5))).bindFromRequest.fold(
err => fuccess(BadRequest),
assessment => {
val color: Color = Color(side == "white")
assessApi.createPlayerAssessment(PlayerAssessment(
_id = id + "/" + color.name,
gameId = id,
white = (color == Color.White),
assessment = assessment,
by = me.id,
date = DateTime.now)).void
}
)
}
def refreshAssess = SecureBody(_.MarkEngine) { implicit ctx =>
me =>
import play.api.data.Forms._

View File

@ -12,7 +12,7 @@ import lila.rating.PerfType
import lila.security.Permission
import lila.user.tube.userTube
import lila.user.{ User => UserModel, UserRepo }
import lila.evaluation.PlayerAggregateAssessment
import lila.evaluation.{PlayerAggregateAssessment}
import views._
object User extends LilaController {
@ -147,7 +147,7 @@ object User extends LilaController {
def mod(username: String) = Secure(_.UserSpy) { implicit ctx =>
me => OptionFuOk(UserRepo named username) { user =>
(Env.evaluation.evaluator find user) zip (Env.security userSpy user.id) zip (Env.mod.assessApi.getResultsByUserId(user.id, 25)) map {
(Env.evaluation.evaluator find user) zip (Env.security userSpy user.id) zip (Env.mod.assessApi.getPlayerAssessmentsByUserId(user.id, 25)) map {
case ((eval, spy), gameResults) => html.user.mod(user, spy, eval, PlayerAggregateAssessment(gameResults))
}
}

View File

@ -1,6 +1,6 @@
@import lila.evaluation.GameResults
@import lila.evaluation.GameAssessments
@(pov: Pov, data: play.api.libs.json.JsObject, pgn: String, analysis: Option[lila.analyse.Analysis], advantageChart: Option[String], tour: Option[lila.tournament.Tournament], timeChart: lila.analyse.TimeChart, cross: Option[lila.game.Crosstable], userTv: Option[User], division: chess.Division, results: GameResults)(implicit ctx: Context)
@(pov: Pov, data: play.api.libs.json.JsObject, pgn: String, analysis: Option[lila.analyse.Analysis], advantageChart: Option[String], tour: Option[lila.tournament.Tournament], timeChart: lila.analyse.TimeChart, cross: Option[lila.game.Crosstable], userTv: Option[User], division: chess.Division, results: GameAssessments)(implicit ctx: Context)
@import pov._

View File

@ -1,6 +1,6 @@
@import lila.evaluation.GameResults
@import lila.evaluation.{ GameAssessments, Display }
@(game: Game, results: GameResults)(implicit ctx: Context)
@(game: Game, results: GameAssessments)(implicit ctx: Context)
<br />
@if(isGranted(_.MarkEngine) && game.analysable && !game.isCorrespondence && game.turns >= 40) {
@game.players.map { p =>
@ -9,30 +9,17 @@
@playerLink(p, cssClass = s"is color-icon ${p.color.name}".some, withOnline = false, mod = true)
</tr>
<tr>
<td>
<select id="@(p.color.name)Assessment" data-val="@(results.color(p.color).fold(0){ _.bestMatch.assessment })">
<option value="0"></option>
<option value="1">Not cheating</option>
<option value="2">Unlikely cheating</option>
<option value="3">Unclear</option>
<option value="4">Likely cheating</option>
<option value="5">Cheating</option>
</select>
</td>
@results.color(p.color).fold{
<td class="noMatch">None</td>
}{ result =>
<td class=@(if(result.bestMatch.positiveMatch) "match" else "partial")>
<a href="@routes.Round.watcher(result.bestMatch.gameId, result.bestMatch.color.name)">
@(result.bestMatch.matchPercentage)%
</a>
<td class="match">
@Display.assessmentString(result.assessment)
</td>
}
</tr>
</table>
}
<div class="assessmentButtons">
<a class="button" id="confirmAssessment">Confirm</a>
<a class="button" id="refreshAssessment">Refresh</a>
</div>
}

View File

@ -82,7 +82,7 @@
}
</div>
}
@if(aggregateAssessment.gameGroupResults.nonEmpty) {
@if(aggregateAssessment.playerAssessments.nonEmpty) {
<div class="evaluation results">
<table class="reportCard">
<thead>
@ -140,47 +140,21 @@
<th>Blurs</th>
<th>Bot</th>
<th><span class="hint--top-left" data-hint="Aggregate match">&Sigma;</span></th>
<th><span class="hint--top-left" data-hint="Primary match">1</span></th>
<th><span class="hint--top-left" data-hint="Secondary match">2</span></th>
<th><span class="hint--top-left" data-hint="Tertiary match">3</span></th>
<th><span class="hint--top-left" data-hint="Quaternary match">4</span></th>
<th><span class="hint--top-left" data-hint="Quinary match">5</span></th>
</tr>
</thead>
<tbody>
@aggregateAssessment.gameGroupResults.map { result =>
@aggregateAssessment.playerAssessments.map { result =>
<tr>
<td><a href="@routes.Round.watcher(result.gameId, result.color.name)">@routes.Round.watcher(result.gameId, result.color.name)</a></td>
<td>@result.sfAvg ± @result.sfSd</td>
<td>@(result.mtAvg/10) ± @(result.mtSd/10)</td>
<td>@(result.blur)%</td>
<td>@(result.blurs)%</td>
<td>@if(result.hold){Yes} else {No}</td>
<td>
<span class="aggregate hint--top-left" data-hint="@result.aggregate.explanation">
<span class="sig_@(result.aggregate.assessment)" data-icon="@Display.dataIcon(result.aggregate)"></span>
<span class="aggregate hint--top-left">
<span class="sig_@(result.assessment)" data-icon="J"></span>
</span>
</td>
<td>
<a href="@routes.Round.watcher(result.bestMatch.gameId, result.bestMatch.color.name)" class="peerLink hint--top-left" data-hint="@result.bestMatch.explanation">
<span class="sig_@(result.bestMatch.assessment)" data-icon="@Display.dataIcon(result.bestMatch)">&nbsp;</span>
<br /><span class="percentage">@(result.bestMatch.matchPercentage)%</span>
</a>
</td>
@Range(0, 4).map { i =>
@result.secondaryMatches.lift(i) match {
case Some(peer) => {
<td>
<a href="@routes.Round.watcher(peer.gameId, peer.color.name)" class="peerLink hint--top-left" data-hint="@peer.explanation">
<span class="sig_@(peer.assessment)" data-icon="@Display.dataIcon(peer)"></span>
<br /><span class="percentage">@(peer.matchPercentage)%</span>
</a>
</td>
}
case _ => {
<td></td>
}
}
}
</tr>
}
</tbody>

View File

@ -210,7 +210,6 @@ POST /mod/:username/title controllers.Mod.setTitle(username: String
GET /mod/:username/communication controllers.Mod.communication(username: String)
POST /mod/:ip/ipban controllers.Mod.ipban(ip: String)
GET /mod/log controllers.Mod.log
POST /mod/$gameId<\w{8}>/$color<white|black>/assess controllers.Mod.assessGame(gameId: String, color: String)
POST /mod/refreshAssess controllers.Mod.refreshAssess
# Wiki

View File

@ -0,0 +1,240 @@
package lila.evaluation
import chess.{ Color }
import lila.game.{ Pov, Game }
import lila.analyse.{ Accuracy, Analysis }
import Math.{ pow, abs, sqrt, E, exp }
import org.joda.time.DateTime
import scalaz.NonEmptyList
case class PlayerAssessment(
_id: String,
gameId: String,
userId: String,
white: Boolean,
assessment: Int, // 1 = Not Cheating, 2 = Unlikely Cheating, 3 = Unknown, 4 = Likely Cheating, 5 = Cheating
date: DateTime,
// meta
sfAvg: Int,
sfSd: Int,
mtAvg: Int,
mtSd: Int,
blurs: Int,
hold: Boolean
) {
val color = Color.apply(white)
}
case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment]) {
import Statistics._
def countAssessmentValue(assessment: Int) = listSum(playerAssessments map {
case a if (a.assessment == assessment) => 1
case _ => 0
})
val cheatingSum = countAssessmentValue(5)
val likelyCheatingSum = countAssessmentValue(4)
val markPri = countAssessmentValue(5) >= 2
val markSec = countAssessmentValue(5) + countAssessmentValue(4) >= 4
val reportPri = countAssessmentValue(5) >= 1
val reportSec = countAssessmentValue(5) + countAssessmentValue(4) >= 2
}
case class GameAssessments(
white: Option[PlayerAssessment],
black: Option[PlayerAssessment]) {
def color(c: Color) = c match {
case Color.White => white
case _ => black
}
}
case class Analysed(game: Game, analysis: Analysis)
case class Assessible(analysed: Analysed) {
import Statistics._
def moveTimes(color: Color): List[Int] =
skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})
def suspiciousErrorRate(color: Color): Boolean =
listAverage(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)) < 15
def alwaysHasAdvantage(color: Color): Boolean = {
def chartFromDifs(difs: List[(Int, Int)], sum: Int = 0): List[Int] =
difs match {
case Nil => Nil
case a :: Nil => List((sum + a._1), (sum + a._1 + a._2))
case a :: b => List((sum + a._1), (sum + a._1 + a._2)) ::: chartFromDifs(b, sum + a._1 + a._2)
}
!chartFromDifs(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis) zip
Accuracy.diffsList(Pov(this.analysed.game, !color), this.analysed.analysis)).exists(_ < -50)
}
def highBlurRate(color: Color): Boolean =
this.analysed.game.playerBlurPercent(color) > 90
def moderateBlurRate(color: Color): Boolean =
this.analysed.game.playerBlurPercent(color) > 70
def consistentMoveTimes(color: Color): Boolean =
moveTimes(color).toNel.map(coefVariation).fold(false)(_ < 0.8)
def noFastMoves(color: Color): Boolean = !moveTimes(color).exists(_ < 10)
def suspiciousHoldAlert(color: Color): Boolean =
this.analysed.game.player(color).hasSuspiciousHoldAlert
def rankCheating(color: Color): Int =
((
suspiciousErrorRate(color),
alwaysHasAdvantage(color),
highBlurRate(color),
moderateBlurRate(color),
consistentMoveTimes(color),
noFastMoves(color),
suspiciousHoldAlert(color)
) match {
// SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds
case (true, true, true, true, true, true, true) => 5 // all true, obvious cheat
case (true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts
case (true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves
case (true, _, _, _, true, true, _) => 5 // high accuracy, consistent move times, no fast moves
case (_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts
case (_, true, true, _, _, _, _) => 4 // always has advantage, high blurs
case (true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line
case (true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves
case (false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage
case (false, false, false, false, false, false, false) => 1 // all false, obviously not cheating
case _ => 1
}).min(this.analysed.game.wonBy(color) match {
case Some(c) if (c) => 5
case _ => 3
})
def sfAvg(color: Color): Int = listAverage(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)).toInt
def sfSd(color: Color): Int = listDeviation(Accuracy.diffsList(Pov(this.analysed.game, color), this.analysed.analysis)).toInt
def mtAvg(color: Color): Int = listAverage(skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})).toInt
def mtSd(color: Color): Int = listDeviation(skip(this.analysed.game.moveTimes.toList, {if (color == Color.White) 0 else 1})).toInt
def blurs(color: Color): Int = this.analysed.game.playerBlurPercent(color)
def hold(color: Color): Boolean = this.analysed.game.player(color).hasSuspiciousHoldAlert
def playerAssessment(color: Color): PlayerAssessment = PlayerAssessment(
_id = this.analysed.game.id + "/" + color.name,
gameId = this.analysed.game.id,
userId = this.analysed.game.player(color).userId.getOrElse(""),
white = (color == Color.White),
assessment = rankCheating(color),
date = DateTime.now,
// meta
sfAvg = sfAvg(color),
sfSd = sfSd(color),
mtAvg = mtAvg(color),
mtSd = mtSd(color),
blurs = blurs(color),
hold = hold(color)
)
val assessments: GameAssessments = GameAssessments(
white = Some(playerAssessment(Color.White)),
black = Some(playerAssessment(Color.Black)))
}
object Display {
def assessmentString(x: Int): String =
x match {
case 1 => "Not cheating"
case 2 => "Unlikely cheating"
case 3 => "Unclear"
case 4 => "Likely cheating"
case 5 => "Cheating"
case 6 => "Undef"
}
}
object Statistics {
import Erf._
import scala.annotation._
def variance[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = {
val avg: Double = optionalAvg.fold(average(a)){n.toDouble}
a.map( i => pow(n.toDouble(i) - avg, 2)).list.sum / a.length
}
def deviation[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = sqrt(variance(a, optionalAvg))
def average[T](a: NonEmptyList[T])(implicit n: Numeric[T]): Double = {
@tailrec def average(a: List[T], sum: T = n.zero, depth: Int = 0): Double = {
a match {
case List() => n.toDouble(sum) / depth
case x :: xs => average(xs, n.plus(sum, x), depth + 1)
}
}
average(a.list)
}
// Coefficient of Variance
def coefVariation(a: NonEmptyList[Int]): Double = sqrt(variance(a)) / average(a)
def intervalToVariance4(interval: Double): Double = pow(interval / 3, 8) // roughly speaking
// Accumulative probability function for normal distributions
def cdf[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
0.5 * (1 + erf(n.toDouble(n.minus(x, avg)) / (n.toDouble(sd)*sqrt(2))))
// The probability that you are outside of abs(x-n) from the mean on both sides
def confInterval[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
1 - cdf(n.abs(x), avg, sd) + cdf(n.times(n.fromInt(-1), n.abs(x)), avg, sd)
def skip[A](l: List[A], n: Int) =
l.zipWithIndex.collect {case (e,i) if ((i+n) % 2) == 0 => e} // (i+1) because zipWithIndex is 0-based
def listSum(xs: List[Int]): Int = xs match {
case Nil => 0
case x :: tail => x + listSum(tail)
}
def listSum(xs: List[Double]): Double = xs match {
case Nil => 0
case x :: tail => x + listSum(tail)
}
def listAverage[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
case Nil => 0
case a :: Nil => n.toDouble(a)
case a :: b => average(NonEmptyList.nel(a, b))
}
def listDeviation[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
case Nil => 0
case _ :: Nil => 0
case a :: b => deviation(NonEmptyList.nel(a, b))
}
}
object Erf {
// constants
val a1: Double = 0.254829592
val a2: Double = -0.284496736
val a3: Double = 1.421413741
val a4: Double = -1.453152027
val a5: Double = 1.061405429
val p: Double = 0.3275911
def erf(x: Double): Double = {
// Save the sign of x
val sign = if (x < 0) -1 else 1
val absx = abs(x)
// A&S formula 7.1.26, rational approximation of error function
val t = 1.0/(1.0 + p*absx);
val y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t*exp(-x*x);
sign*y
}
}

View File

@ -1,356 +0,0 @@
package lila.evaluation
import Math.{pow, E, PI, log, sqrt, abs, exp, round}
import org.joda.time.DateTime
import scalaz.NonEmptyList
import chess.{ Color }
import lila.game.{ Pov, Game }
import lila.analyse.{ Accuracy, Analysis }
case class PlayerAssessment(
_id: String,
gameId: String,
white: Boolean, // Side of the game being analysed
assessment: Int, // 1 = Not Cheating, 2 = Unlikely Cheating, 3 = Unknown, 4 = Likely Cheating, 5 = Cheating
by: String, // moderator ID
date: DateTime) {
val color = Color(white)
}
case class PeerGame(
gameId: String,
white: Boolean,
positiveMatch: Boolean,
matchPercentage: Int,
assessment: Int
) {
val color = Color(white)
val explanation: String =
(if (positiveMatch) "Matches " else "Partially matches ") + "with " +
(assessment match {
case 1 => "a non cheating"
case 2 => "an unlikely cheating"
case 3 => "an unclear"
case 4 => "a likely cheating"
case 5 => "a cheating"
case _ => "Undefined"
}) + " game at " + matchPercentage + "% confidence"
}
case class PlayerAggregateAssessment(
gameGroupResults: List[GameGroupResult]
) {
import Statistics.listSum
def sumAssessment(x: Int): Int =
listSum(gameGroupResults.map { result =>
if (result.aggregate.assessment == x && (result.aggregate.positiveMatch || result.aggregate.confidence >= 80)) 1
else 0
})
val cheatingSum: Int = sumAssessment(5)
val likelyCheatingSum: Int = sumAssessment(4)
val unclearSum: Int = sumAssessment(3)
val markPri: Boolean = cheatingSum >= 2
val markSec: Boolean = cheatingSum + likelyCheatingSum >= 4
val reportPri: Boolean = cheatingSum >= 1
val reportSec: Boolean = cheatingSum + likelyCheatingSum >= 2
}
case class AggregateAssessment(
assessment: Int,
confidence: Int,
positiveMatch: Boolean
) {
val explanation: String =
(if (positiveMatch) "Matches " else "Partially matches ") +
(assessment match {
case 1 => "Not Cheating"
case 2 => "Unlikely Cheating"
case 3 => "Unclear"
case 4 => "Likely Cheating"
case 5 => "Cheating"
case _ => "Undefined"
}) + " at " + confidence + "% confidence"
}
case class GameGroupResult(
_id: String, // sourceGameId + "/" + sourceGameColor
userId: String, // The userId of the player being evaluated
gameId: String, // The game being talked about
white: Boolean, // The side of the game being talked about
bestMatch: PeerGame,
secondaryMatches: List[PeerGame],
date: DateTime,
// Meta infos
sfAvg: Int,
sfSd: Int,
mtAvg: Int,
mtSd: Int,
blur: Int,
hold: Boolean
) {
import Statistics.{listSum, listAverage}
val color = Color(white)
val aggregate: AggregateAssessment = {
val peers = bestMatch :: secondaryMatches
AggregateAssessment(
round(listSum(peers.map {
case a if (a.positiveMatch) => 4 * a.matchPercentage * a.assessment
case a => a.matchPercentage * a.assessment
}).toDouble / listSum(peers.map {
case a if (a.positiveMatch) => 4 * a.matchPercentage
case a => a.matchPercentage
})).toInt,
bestMatch.matchPercentage,
peers.exists(_.positiveMatch)
)
}
}
object Display {
def assessmentString(x: Int): String =
x match {
case 1 => "Not Cheating" // Not cheating
case 2 => "Unlikely Cheating" // Unlikely Cheating
case 3 => "Unclear" // Unclear
case 4 => "Likely Cheating" // Likely Cheating
case 5 => "Cheating" // Cheating
case _ => "Undef"
}
def dataIcon(positiveMatch: Boolean, confidence: Int): String = {
(positiveMatch, confidence) match {
case (true, _) => "J"
case (_, a) if (a >= 80) => "l"
case _ => "K"
}
}
def dataIcon(x: AggregateAssessment): String = dataIcon(x.positiveMatch, x.confidence)
def dataIcon(x: PeerGame): String = dataIcon(x.positiveMatch, x.matchPercentage)
}
case class GameResults(
white: Option[GameGroupResult],
black: Option[GameGroupResult]
) {
def color(c: Color): Option[GameGroupResult] = c.fold(white, black)
}
case class Rating(perf: Int, interval: Int)
case class Similarity(a: Double, threshold: Double = 0.9) {
def apply: Double = a.min(1).max(0)
val matches: Boolean = this.apply >= threshold
}
case class Analysed(game: Game, analysis: Analysis)
case class MatchAndSig(matches: Boolean, significance: Double)
case class GameGroup(analysed: Analysed, color: Color, assessment: Option[Int] = None) {
import Statistics._
def compareMoveTimes (that: GameGroup): Similarity = {
val thisMt: List[Int] = skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})
val thatMt: List[Int] = skip(that.analysed.game.moveTimes.toList, {if (that.color == Color.White) 0 else 1})
listToListSimilarity(thisMt, thatMt, 0.8)
}
def compareSfAccuracies (that: GameGroup): Similarity = {
def chartDifs(difs: List[(Int, Int)], sum: Int = 0): List[Int] = {
difs match {
case Nil => Nil
case a :: Nil => List((sum + a._1), (sum + a._1 + a._2))
case a :: b => List((sum + a._1), (sum + a._1 + a._2)) ::: chartDifs(b, sum + a._1 + a._2)
}
}
def averageDif(chart: List[(Int, Int)]): Double = listAverage(chart.map{a => abs(a._1 - a._2)})
if (abs(this.analysed.game.playedTurns - that.analysed.game.playedTurns) <= 5) {
val avgDif = averageDif(
(
chartDifs(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis) zip
Accuracy.diffsList(Pov(this.analysed.game, !this.color), this.analysed.analysis))
) zip (
chartDifs(Accuracy.diffsList(Pov(that.analysed.game, that.color), that.analysed.analysis) zip
Accuracy.diffsList(Pov(that.analysed.game, !that.color), that.analysed.analysis))
))
Similarity(((300d - avgDif) / 300d).max(0d), 0.67)
} else Similarity(0)
}
def compareBlurRates (that: GameGroup): Similarity = pointToPointSimilarity(
(200 * this.analysed.game.player(this.color).blurs / this.analysed.game.turns).toInt,
(200 * that.analysed.game.player(that.color).blurs / that.analysed.game.turns).toInt,
5d
)
def compareHoldAlerts (that: GameGroup): Similarity = Similarity(
if (this.analysed.game.player(this.color).hasSuspiciousHoldAlert == that.analysed.game.player(that.color).hasSuspiciousHoldAlert) 1 else 0,
0.9
)
val sfAvg: Int = listAverage(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis)).toInt
val sfSd: Int = listDeviation(Accuracy.diffsList(Pov(this.analysed.game, this.color), this.analysed.analysis)).toInt
val mtAvg: Int = listAverage(skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})).toInt
val mtSd: Int = listDeviation(skip(this.analysed.game.moveTimes.toList, {if (this.color == Color.White) 0 else 1})).toInt
val blurs: Int = (200 * this.analysed.game.player(this.color).blurs / this.analysed.game.turns).toInt;
val hold: Boolean = this.analysed.game.player(this.color).hasSuspiciousHoldAlert
def similarityTo (that: GameGroup): MatchAndSig = {
// Calls compare functions to determine how similar `this` and `that` are to each other
val sfComparison = compareSfAccuracies(that)
val similarities = NonEmptyList(
compareMoveTimes(that),
compareSfAccuracies(that),
compareBlurRates(that),
compareHoldAlerts(that)
)
MatchAndSig(
allSimilar(similarities), // Are they all similar?
ssd(similarities) // How significant is the similarity?
)
}
}
object Statistics {
import Erf._
import scala.annotation._
def variance[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = {
val avg: Double = optionalAvg.fold(average(a)){n.toDouble}
a.map( i => pow(n.toDouble(i) - avg, 2)).list.sum / a.length
}
def deviation[T](a: NonEmptyList[T], optionalAvg: Option[T] = None)(implicit n: Numeric[T]): Double = sqrt(variance(a, optionalAvg))
def average[T](a: NonEmptyList[T])(implicit n: Numeric[T]): Double = {
@tailrec def average(a: List[T], sum: T = n.zero, depth: Int = 0): Double = {
a match {
case List() => n.toDouble(sum) / depth
case x :: xs => average(xs, n.plus(sum, x), depth + 1)
}
}
average(a.list)
}
def setToSetSimilarity(avgA: Double, avgB: Double, varA: Double, varB: Double, threshold: Double): Similarity = {
val sim = Similarity(
pow(E, (-0.25) * ( log( 0.25 * ((varA / varB) + (varB / varA) + 2) ) + pow(avgA - avgB, 2) / ( varA + varB ) )),
threshold)
if (sim.a.isNaN || sim.a.isInfinity) Similarity(1, threshold)
else sim
}
// Bhattacharyya Coefficient
def setToSetSimilarity[T](a: NonEmptyList[T], b: NonEmptyList[T], threshold: Double = 0.9)(implicit n: Numeric[T]): Similarity = {
val aDouble: NonEmptyList[Double] = a.map(n.toDouble)
val bDouble: NonEmptyList[Double] = b.map(n.toDouble)
val avgA = average(a)
val avgB = average(b)
val varA = pow(variance(aDouble, Some(avgA)), 2)
val varB = pow(variance(bDouble, Some(avgB)), 2)
setToSetSimilarity(avgA, avgB, varA, varB, threshold)
}
def listToListSimilarity[T](x: List[T], y: List[T], threshold: Double = 0.9)(implicit n: Numeric[T]): Similarity =
(x, y) match {
case (Nil, Nil) => Similarity(1) // Both empty
case (Nil, _ :: _) => Similarity(0) // One empty, The other with some
case (_ :: _, Nil) => Similarity(0)
case (a :: Nil, b :: Nil) => pointToPointSimilarity(a, b, 5d) // Both have one
case (a :: Nil, b :: c) => pointToSetSimilarity(a, NonEmptyList.nel(b, c)) // One with one element, the other with many
case (a :: b, c :: Nil) => pointToSetSimilarity(c, NonEmptyList.nel(a, b))
case (a :: b, c :: d) => setToSetSimilarity(NonEmptyList.nel(a, b), NonEmptyList.nel(c, d), threshold) // Both have many
}
def pointToSetSimilarity[T](x: T, set: NonEmptyList[T])(implicit n: Numeric[T]): Similarity = Similarity(
confInterval(n.toDouble(x), average(set), sqrt(variance(set))),
0.9
)
def pointToPointSimilarity[T](a: T, b: T, variance: Double)(implicit n: Numeric[T]): Similarity = Similarity(
(a, b) match {
case (a, b) if (a == b || n.toDouble(n.abs(n.minus(a, b))) < variance) => 1
case _ => 0
}
)
// Coefficient of Variance
def coefVariation(a: NonEmptyList[Int]): Double = sqrt(variance(a)) / average(a)
def intervalToVariance4(interval: Double): Double = pow(interval / 3, 8) // roughly speaking
// Accumulative probability function for normal distributions
def cdf[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
0.5 * (1 + erf(n.toDouble(n.minus(x, avg)) / (n.toDouble(sd)*sqrt(2))))
// The probability that you are outside of abs(x-n) from the mean on both sides
def confInterval[T](x: T, avg: T, sd: T)(implicit n: Numeric[T]): Double =
1 - cdf(n.abs(x), avg, sd) + cdf(n.times(n.fromInt(-1), n.abs(x)), avg, sd)
// all Similarities in the non empty list are similar
def allSimilar(a: NonEmptyList[Similarity]): Boolean = a.list.forall( _.matches )
def allSimilar(a: Similarity, b: List[Similarity]): Boolean = allSimilar(NonEmptyList.nel(a, b))
// Square Sum Distance
def ssd(a: NonEmptyList[Similarity]): Double = sqrt(a.map(x => pow(x.apply, 2)).list.sum / a.size)
def ssd(a: Similarity, b: List[Similarity]): Double = ssd(NonEmptyList.nel(a, b))
def skip[A](l: List[A], n: Int) =
l.zipWithIndex.collect {case (e,i) if ((i+n) % 2) == 0 => e} // (i+1) because zipWithIndex is 0-based
def listSum(xs: List[Int]): Int = xs match {
case Nil => 0
case x :: tail => x + listSum(tail)
}
def listAverage[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
case Nil => 0
case a :: Nil => n.toDouble(a)
case a :: b => average(NonEmptyList.nel(a, b))
}
def listDeviation[T](x: List[T])(implicit n: Numeric[T]): Double = x match {
case Nil => 0
case _ :: Nil => 0
case a :: b => deviation(NonEmptyList.nel(a, b))
}
}
object Erf {
// constants
val a1: Double = 0.254829592
val a2: Double = -0.284496736
val a3: Double = 1.421413741
val a4: Double = -1.453152027
val a5: Double = 1.061405429
val p: Double = 0.3275911
def erf(x: Double): Double = {
// Save the sign of x
val sign = if (x < 0) -1 else 1
val absx = abs(x)
// A&S formula 7.1.26, rational approximation of error function
val t = 1.0/(1.0 + p*absx);
val y = 1.0 - (((((a5*t + a4)*t) + a3)*t + a2)*t + a1)*t*exp(-x*x);
sign*y
}
}

View File

@ -3,7 +3,7 @@ package lila.mod
import lila.analyse.{ Analysis, AnalysisRepo }
import lila.db.Types.Coll
import lila.db.BSON.BSONJodaDateTimeHandler
import lila.evaluation.{ PlayerAssessment, GameGroupResult, GameResults, GameGroup, Analysed, PeerGame, MatchAndSig }
import lila.evaluation.{ Analysed, PlayerAssessment, GameAssessments, Assessible }
import lila.game.Game
import lila.game.{ Game, GameRepo }
import org.joda.time.DateTime
@ -13,104 +13,45 @@ import scala.concurrent._
import chess.Color
final class AssessApi(collRef: Coll, collRes: Coll, logApi: ModlogApi) {
final class AssessApi(collAssessments: Coll, logApi: ModlogApi) {
private implicit val bestMatchBSONhandler = Macros.handler[PeerGame]
private implicit val playerAssessmentBSONhandler = Macros.handler[PlayerAssessment]
private implicit val gameGroupResultBSONhandler = Macros.handler[GameGroupResult]
def createPlayerAssessment(assessed: PlayerAssessment) =
collRef.update(BSONDocument("_id" -> assessed._id), assessed, upsert = true) >>
logApi.assessGame(assessed.by, assessed.gameId, assessed.color.name, assessed.assessment) >>
refreshAssess(assessed.gameId)
collAssessments.update(BSONDocument("_id" -> assessed._id), assessed, upsert = true).void
def createResult(result: GameGroupResult) =
collRes.update(BSONDocument("_id" -> result._id), result, upsert = true).void
def getPlayerAssessments(max: Int): Fu[List[PlayerAssessment]] = collRef.find(BSONDocument())
.cursor[PlayerAssessment]
.collect[List](max)
def getPlayerAssessmentById(id: String) = collRef.find(BSONDocument("_id" -> id))
def getPlayerAssessmentById(id: String) =
collAssessments.find(BSONDocument("_id" -> id))
.one[PlayerAssessment]
def getResultsByUserId(userId: String, nb: Int = 100) = collRes.find(BSONDocument("userId" -> userId))
.sort(BSONDocument("bestMatch.assessment" -> -1, "bestMatch.positiveMatch" -> -1, "bestMatch.matchPercentage" -> -1))
.cursor[GameGroupResult]
def getPlayerAssessmentsByUserId(userId: String, nb: Int = 100) =
collAssessments.find(BSONDocument("userId" -> userId))
.sort(BSONDocument("assessment" -> -1))
.cursor[PlayerAssessment]
.collect[List](nb)
def getResultsByGameIdAndColor(gameId: String, color: Color) = collRes.find(BSONDocument("_id" -> (gameId + "/" + color.name)))
.one[GameGroupResult]
def getResultsByGameIdAndColor(gameId: String, color: Color) =
collAssessments.find(BSONDocument("_id" -> (gameId + "/" + color.name)))
.one[PlayerAssessment]
def getResultsByGameId(gameId: String): Fu[GameResults] =
def getGameResultsById(gameId: String) =
getResultsByGameIdAndColor(gameId, Color.White) zip
getResultsByGameIdAndColor(gameId, Color.Black) map
GameResults.tupled
getResultsByGameIdAndColor(gameId, Color.Black) map {
a => GameAssessments(a._1, a._2)
}
def refreshAssess(gameId: String): Funit =
GameRepo.game(gameId) zip
AnalysisRepo.doneById(gameId) map {
case (Some(g), Some(a)) => onAnalysisReady(g, a)
case _ => funit
case (Some(g), Some(a)) => onAnalysisReady(g, a)
case _ => funit
}
def onAnalysisReady(game: Game, analysis: Analysis): Funit = {
def playerAssessmentGameGroups: Fu[List[GameGroup]] =
getPlayerAssessments(400) flatMap { assessments =>
GameRepo.gameOptions(assessments.map(_.gameId)) flatMap { games =>
AnalysisRepo.doneByIds(assessments.map(_.gameId)) map { analyses =>
assessments zip games zip analyses flatMap {
case ((assessment, Some(game)), Some(analysis)) =>
Some(GameGroup(Analysed(game, analysis), assessment.color, Some(assessment.assessment)))
case _ => None
}
}
}
}
def buildGameGroupResult(source: GameGroup, assessments: List[GameGroup], nb: Int = 5): Option[GameGroupResult] =
(assessments.map(source.similarityTo) zip assessments).sortBy(-_._1.significance).take(nb).map {
case (matchAndSig: MatchAndSig, gameGroup: GameGroup) =>
PeerGame(
gameId = gameGroup.analysed.game.id,
white = gameGroup.color.white,
positiveMatch = matchAndSig.matches,
matchPercentage = (100 * matchAndSig.significance).toInt,
assessment = gameGroup.assessment.getOrElse(1).min(source.analysed.game.wonBy(source.color) match {
case Some(color) if (color) => 5
case _ => 3
})
)
} match {
case a :: b =>
Some(GameGroupResult(
_id = source.analysed.game.id + "/" + source.color.name,
userId = source.analysed.game.player(source.color).userId.getOrElse(""),
gameId = source.analysed.game.id,
white = source.color.white,
bestMatch = a,
secondaryMatches = b,
date = DateTime.now,
sfAvg = source.sfAvg,
sfSd = source.sfSd,
mtAvg = source.mtAvg,
mtSd = source.mtSd,
blur = source.blurs,
hold = source.hold
))
case _ => None
}
if (!game.isCorrespondence && game.turns >= 40 && game.mode.rated) {
val whiteGameGroup = GameGroup(Analysed(game, analysis), Color.White)
val blackGameGroup = GameGroup(Analysed(game, analysis), Color.Black)
playerAssessmentGameGroups flatMap {
a => {
buildGameGroupResult(whiteGameGroup, a).fold(funit){createResult} >>
buildGameGroupResult(blackGameGroup, a).fold(funit){createResult}
}
}
val gameAssessments: GameAssessments = Assessible(Analysed(game, analysis)).assessments
gameAssessments.white.fold(funit){createPlayerAssessment} >>
gameAssessments.black.fold(funit){createPlayerAssessment}
} else funit
}
}

View File

@ -13,8 +13,7 @@ final class Env(
firewall: Firewall,
userSpy: String => Fu[UserSpy]) {
private val CollectionPlayerAssessment = config getString "collection.crossref"
private val CollectionResult = config getString "collection.result"
private val CollectionPlayerAssessment= config getString "collection.player_assessment"
private val CollectionBoosting = config getString "collection.boosting"
private val CollectionModlog = config getString "collection.modlog"
private val ActorName = config getString "actor.name"
@ -24,7 +23,7 @@ final class Env(
lazy val logApi = new ModlogApi
lazy val assessApi = new AssessApi(db(CollectionPlayerAssessment), db(CollectionResult), logApi)
lazy val assessApi = new AssessApi(db(CollectionPlayerAssessment), logApi)
lazy val api = new ModApi(
logApi = logApi,

View File

@ -2032,38 +2032,12 @@ lichess.storage = {
});
if ($('#refreshAssessment').length) {
$('#whiteAssessment').val($('#whiteAssessment').data('val'));
$('#blackAssessment').val($('#blackAssessment').data('val'));
var sendAssessment = function(side, e) {
if ($(e.target).val() != "0") {
$.post('/mod/' + data.game.id + '/' + side + '/assess', {
assessment: $(e.target).val()
});
}
}
$('#whiteAssessment').change(sendAssessment.bind(null, 'white'));
$('#blackAssessment').change(sendAssessment.bind(null, 'black'));
$('#refreshAssessment').click(function() {
$.post('/mod/refreshAssess', {
assess: data.game.id,
success: function(){ setTimeout(function(){ location.reload(); }, 3000) }
});
});
$('#confirmAssessment').click(function() {
if ($('#whiteAssessment').val() != "0") {
$.post('/mod/' + data.game.id + '/white/assess', {
assessment: $('#whiteAssessment').val()
});
}
if ($('#blackAssessment').val() != "0") {
$.post('/mod/' + data.game.id + '/black/assess', {
assessment: $('#blackAssessment').val()
});
}
});
}
}

View File

@ -3,7 +3,6 @@ body {
font: 12px 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, Sans-Serif;
color: #909090;
background: #05121b; /* url("bluegrid.jpg") center repeat; */
margin-top: 50px;
}
h1 {