commit
b9527f234f
|
@ -141,6 +141,42 @@
|
|||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="extra_stats">
|
||||
<caption>Average Centipawn Loss Given:</caption>
|
||||
<tr>
|
||||
<th>Blurs</th>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgBlurs</strong>
|
||||
<br/>high
|
||||
</td>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgNoBlurs</strong>
|
||||
<br/>low
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Move Times</th>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgLowVar</strong>
|
||||
<br/>low variance
|
||||
</td>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgHighVar</strong>
|
||||
<br/>high variance
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Hold Alert</th>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgHold</strong>
|
||||
<br/>alert
|
||||
</td>
|
||||
<td>
|
||||
<strong>@aggregateAssessment.sfAvgNoHold</strong>
|
||||
<br/>no alert
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<table class="slist">
|
||||
<thead>
|
||||
<tr>
|
||||
|
|
26
modules/evaluation/src/main/AccountAction.scala
Normal file
26
modules/evaluation/src/main/AccountAction.scala
Normal file
|
@ -0,0 +1,26 @@
|
|||
package lila.evaluation
|
||||
|
||||
sealed trait AccountAction {
|
||||
val description: String
|
||||
val colorClass: String
|
||||
override def toString = description
|
||||
}
|
||||
|
||||
object AccountAction {
|
||||
case object EngineAndBan extends AccountAction {
|
||||
val description: String = "Mark and IP ban"
|
||||
val colorClass = "4"
|
||||
}
|
||||
case object Engine extends AccountAction {
|
||||
val description: String = "Mark as engine"
|
||||
val colorClass = "3"
|
||||
}
|
||||
case object Report extends AccountAction {
|
||||
val description: String = "Report to mods"
|
||||
val colorClass = "2"
|
||||
}
|
||||
case object Nothing extends AccountAction {
|
||||
val description: String = "Not suspicious"
|
||||
val colorClass = "1"
|
||||
}
|
||||
}
|
103
modules/evaluation/src/main/Assessible.scala
Normal file
103
modules/evaluation/src/main/Assessible.scala
Normal file
|
@ -0,0 +1,103 @@
|
|||
package lila.evaluation
|
||||
|
||||
import chess.Color
|
||||
import org.joda.time.DateTime
|
||||
import lila.game.{ Game, Pov }
|
||||
import lila.analyse.{ Accuracy, Analysis }
|
||||
import Math.signum
|
||||
|
||||
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 =
|
||||
!analysed.analysis.infos.exists{ info =>
|
||||
info.score.fold(info.mate.fold(false){ a => (signum(a).toInt == color.fold(-1, 1)) }){ cp =>
|
||||
color.fold(cp.centipawns < -100, cp.centipawns > 100)}
|
||||
}
|
||||
|
||||
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.5)
|
||||
|
||||
def noFastMoves(color: Color): Boolean = moveTimes(color).count(_ < 10) <= 2
|
||||
|
||||
def suspiciousHoldAlert(color: Color): Boolean =
|
||||
this.analysed.game.player(color).hasSuspiciousHoldAlert
|
||||
|
||||
def flags(color: Color): PlayerFlags = PlayerFlags(
|
||||
suspiciousErrorRate(color),
|
||||
alwaysHasAdvantage(color),
|
||||
highBlurRate(color),
|
||||
moderateBlurRate(color),
|
||||
consistentMoveTimes(color),
|
||||
noFastMoves(color),
|
||||
suspiciousHoldAlert(color)
|
||||
)
|
||||
|
||||
def rankCheating(color: Color): Int =
|
||||
(flags(color) match {
|
||||
// SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds
|
||||
case PlayerFlags(true, true, true, true, true, true, true) => 5 // all true, obvious cheat
|
||||
case PlayerFlags(true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts
|
||||
case PlayerFlags(_, true, _, _, _, true, true) => 5 // always has advantage, no fast moves, hold alerts
|
||||
case PlayerFlags(true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves
|
||||
|
||||
case PlayerFlags(true, _, _, _, true, true, _) => 4 // high accuracy, consistent move times, no fast moves
|
||||
case PlayerFlags(true, _, _, true, _, true, _) => 4 // high accuracy, moderate blurs, no fast moves
|
||||
case PlayerFlags(_, true, _, true, true, _, _) => 4 // always has advantage, moderate blurs, highly consistent move times
|
||||
case PlayerFlags(_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts
|
||||
case PlayerFlags(_, true, true, _, _, _, _) => 4 // always has advantage, high blurs
|
||||
|
||||
case PlayerFlags(true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line
|
||||
|
||||
case PlayerFlags(true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves
|
||||
|
||||
case PlayerFlags(false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage
|
||||
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
|
||||
flags = flags(color),
|
||||
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)))
|
||||
}
|
47
modules/evaluation/src/main/Display.scala
Normal file
47
modules/evaluation/src/main/Display.scala
Normal file
|
@ -0,0 +1,47 @@
|
|||
package lila.evaluation
|
||||
|
||||
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 _ => "Undef"
|
||||
}
|
||||
|
||||
def stockfishSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.suspiciousErrorRate, pa.flags.alwaysHasAdvantage) match {
|
||||
case (true, true) => 5
|
||||
case (true, false) => 4
|
||||
case (false, true) => 3
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def moveTimeSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.consistentMoveTimes, pa.flags.noFastMoves) match {
|
||||
case (true, true) => 5
|
||||
case (true, false) => 4
|
||||
case (false, true) => 3
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def blurSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.highBlurRate, pa.flags.moderateBlurRate) match {
|
||||
case (true, _) => 5
|
||||
case (_, true) => 4
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def holdSig(pa: PlayerAssessment): Int = if (pa.flags.suspiciousHoldAlert) 5 else 1
|
||||
|
||||
def emoticon(assessment: Int): String = assessment match {
|
||||
case 5 => ">:("
|
||||
case 4 => ":("
|
||||
case 3 => ":|"
|
||||
case 2 => ":)"
|
||||
case 1 => ":D"
|
||||
case _ => ":S"
|
||||
}
|
||||
}
|
|
@ -1,342 +0,0 @@
|
|||
package lila.evaluation
|
||||
|
||||
import chess.{ Color }
|
||||
import lila.game.{ Pov, Game }
|
||||
import lila.analyse.{ Accuracy, Analysis, Info }
|
||||
import lila.user.User
|
||||
import Math.{ pow, abs, sqrt, E, exp, signum }
|
||||
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
|
||||
flags: PlayerFlags,
|
||||
sfAvg: Int,
|
||||
sfSd: Int,
|
||||
mtAvg: Int,
|
||||
mtSd: Int,
|
||||
blurs: Int,
|
||||
hold: Boolean
|
||||
) {
|
||||
val color = Color.apply(white)
|
||||
}
|
||||
|
||||
sealed trait AccountAction {
|
||||
val description: String
|
||||
val colorClass: String
|
||||
override def toString = description
|
||||
}
|
||||
|
||||
object AccountAction {
|
||||
case object EngineAndBan extends AccountAction {
|
||||
val description: String = "Mark and IP ban"
|
||||
val colorClass = "4"
|
||||
}
|
||||
case object Engine extends AccountAction {
|
||||
val description: String = "Mark as engine"
|
||||
val colorClass = "3"
|
||||
}
|
||||
case object Report extends AccountAction {
|
||||
val description: String = "Report to mods"
|
||||
val colorClass = "2"
|
||||
}
|
||||
case object Nothing extends AccountAction {
|
||||
val description: String = "Not suspicious"
|
||||
val colorClass = "1"
|
||||
}
|
||||
}
|
||||
|
||||
case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment],
|
||||
relatedUsers: List[String],
|
||||
relatedCheaters: List[String]) {
|
||||
import Statistics._
|
||||
import AccountAction._
|
||||
|
||||
def action = {
|
||||
val markable: Boolean = (
|
||||
(cheatingSum >= 2 || cheatingSum + likelyCheatingSum >= 4)
|
||||
// more than 5 percent of games are cheating
|
||||
&& (cheatingSum.toDouble / assessmentsCount >= 0.05 - relationModifier
|
||||
// or more than 10 percent of games are likely cheating
|
||||
|| (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.10 - relationModifier)
|
||||
)
|
||||
|
||||
val reportable: Boolean = (
|
||||
(cheatingSum >= 1 || cheatingSum + likelyCheatingSum >= 2)
|
||||
// more than 2 percent of games are cheating
|
||||
&& (cheatingSum.toDouble / assessmentsCount >= 0.02 - relationModifier
|
||||
// or more than 5 percent of games are likely cheating
|
||||
|| (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.05 - relationModifier)
|
||||
)
|
||||
|
||||
val bannable: Boolean = (relatedCheatersCount == relatedUsersCount) && relatedUsersCount >= 1
|
||||
|
||||
if (markable && bannable) EngineAndBan
|
||||
else if (markable) Engine
|
||||
else if (reportable) Report
|
||||
else Nothing
|
||||
}
|
||||
|
||||
def countAssessmentValue(assessment: Int) = listSum(playerAssessments map {
|
||||
case a if (a.assessment == assessment) => 1
|
||||
case _ => 0
|
||||
})
|
||||
|
||||
val relatedCheatersCount = relatedCheaters.distinct.size
|
||||
val relatedUsersCount = relatedUsers.distinct.size
|
||||
val assessmentsCount = playerAssessments.size match {
|
||||
case 0 => 1
|
||||
case a => a
|
||||
}
|
||||
val relationModifier = if (relatedUsersCount >= 1) 0.02 else 0
|
||||
val cheatingSum = countAssessmentValue(5)
|
||||
val likelyCheatingSum = countAssessmentValue(4)
|
||||
}
|
||||
|
||||
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 PlayerFlags(
|
||||
suspiciousErrorRate: Boolean,
|
||||
alwaysHasAdvantage: Boolean,
|
||||
highBlurRate: Boolean,
|
||||
moderateBlurRate: Boolean,
|
||||
consistentMoveTimes: Boolean,
|
||||
noFastMoves: Boolean,
|
||||
suspiciousHoldAlert: Boolean)
|
||||
|
||||
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 =
|
||||
!analysed.analysis.infos.exists{ info =>
|
||||
info.score.fold(info.mate.fold(false){ a => (signum(a).toInt == color.fold(-1, 1)) }){ cp =>
|
||||
color.fold(cp.centipawns < -100, cp.centipawns > 100)}
|
||||
}
|
||||
|
||||
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.5)
|
||||
|
||||
def noFastMoves(color: Color): Boolean = moveTimes(color).count(_ < 10) <= 2
|
||||
|
||||
def suspiciousHoldAlert(color: Color): Boolean =
|
||||
this.analysed.game.player(color).hasSuspiciousHoldAlert
|
||||
|
||||
def flags(color: Color): PlayerFlags = PlayerFlags(
|
||||
suspiciousErrorRate(color),
|
||||
alwaysHasAdvantage(color),
|
||||
highBlurRate(color),
|
||||
moderateBlurRate(color),
|
||||
consistentMoveTimes(color),
|
||||
noFastMoves(color),
|
||||
suspiciousHoldAlert(color)
|
||||
)
|
||||
|
||||
def rankCheating(color: Color): Int =
|
||||
(flags(color) match {
|
||||
// SF1 SF2 BLR1 BLR2 MTs1 MTs2 Holds
|
||||
case PlayerFlags(true, true, true, true, true, true, true) => 5 // all true, obvious cheat
|
||||
case PlayerFlags(true, _, _, _, _, true, true) => 5 // high accuracy, no fast moves, hold alerts
|
||||
case PlayerFlags(_, true, _, _, _, true, true) => 5 // always has advantage, no fast moves, hold alerts
|
||||
case PlayerFlags(true, _, true, _, _, true, _) => 5 // high accuracy, high blurs, no fast moves
|
||||
|
||||
case PlayerFlags(true, _, _, _, true, true, _) => 4 // high accuracy, consistent move times, no fast moves
|
||||
case PlayerFlags(true, _, _, true, _, true, _) => 4 // high accuracy, moderate blurs, no fast moves
|
||||
case PlayerFlags(_, true, _, true, true, _, _) => 4 // always has advantage, moderate blurs, highly consistent move times
|
||||
case PlayerFlags(_, true, _, _, _, _, true) => 4 // always has advantage, hold alerts
|
||||
case PlayerFlags(_, true, true, _, _, _, _) => 4 // always has advantage, high blurs
|
||||
|
||||
case PlayerFlags(true, _, _, false, false, true, _) => 3 // high accuracy, no fast moves, but doesn't blur or flat line
|
||||
|
||||
case PlayerFlags(true, _, _, _, _, false, _) => 2 // high accuracy, but has fast moves
|
||||
|
||||
case PlayerFlags(false, false, _, _, _, _, _) => 1 // low accuracy, doesn't hold advantage
|
||||
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
|
||||
flags = flags(color),
|
||||
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 _ => "Undef"
|
||||
}
|
||||
|
||||
def stockfishSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.suspiciousErrorRate, pa.flags.alwaysHasAdvantage) match {
|
||||
case (true, true) => 5
|
||||
case (true, false) => 4
|
||||
case (false, true) => 3
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def moveTimeSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.consistentMoveTimes, pa.flags.noFastMoves) match {
|
||||
case (true, true) => 5
|
||||
case (true, false) => 4
|
||||
case (false, true) => 3
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def blurSig(pa: PlayerAssessment): Int =
|
||||
(pa.flags.highBlurRate, pa.flags.moderateBlurRate) match {
|
||||
case (true, _) => 5
|
||||
case (_, true) => 4
|
||||
case _ => 1
|
||||
}
|
||||
|
||||
def holdSig(pa: PlayerAssessment): Int = if (pa.flags.suspiciousHoldAlert) 5 else 1
|
||||
|
||||
def emoticon(assessment: Int): String = assessment match {
|
||||
case 5 => ">:("
|
||||
case 4 => ":("
|
||||
case 3 => ":|"
|
||||
case 2 => ":)"
|
||||
case 1 => ":D"
|
||||
case _ => "\\o/"
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
12
modules/evaluation/src/main/GameAssessments.scala
Normal file
12
modules/evaluation/src/main/GameAssessments.scala
Normal file
|
@ -0,0 +1,12 @@
|
|||
package lila.evaluation
|
||||
|
||||
import chess.Color
|
||||
|
||||
case class GameAssessments(
|
||||
white: Option[PlayerAssessment],
|
||||
black: Option[PlayerAssessment]) {
|
||||
def color(c: Color) = c match {
|
||||
case Color.White => white
|
||||
case _ => black
|
||||
}
|
||||
}
|
131
modules/evaluation/src/main/PlayerAggregateAssessment.scala
Normal file
131
modules/evaluation/src/main/PlayerAggregateAssessment.scala
Normal file
|
@ -0,0 +1,131 @@
|
|||
package lila.evaluation
|
||||
|
||||
import chess.Color
|
||||
import org.joda.time.DateTime
|
||||
|
||||
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
|
||||
flags: PlayerFlags,
|
||||
sfAvg: Int,
|
||||
sfSd: Int,
|
||||
mtAvg: Int,
|
||||
mtSd: Int,
|
||||
blurs: Int,
|
||||
hold: Boolean
|
||||
) {
|
||||
val color = Color.apply(white)
|
||||
}
|
||||
|
||||
case class PlayerAggregateAssessment(playerAssessments: List[PlayerAssessment],
|
||||
relatedUsers: List[String],
|
||||
relatedCheaters: List[String]) {
|
||||
import Statistics._
|
||||
import AccountAction._
|
||||
|
||||
def action = {
|
||||
val markable: Boolean = (
|
||||
(cheatingSum >= 2 || cheatingSum + likelyCheatingSum >= 4)
|
||||
// more than 5 percent of games are cheating
|
||||
&& (cheatingSum.toDouble / assessmentsCount >= 0.05 - relationModifier
|
||||
// or more than 10 percent of games are likely cheating
|
||||
|| (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.10 - relationModifier)
|
||||
)
|
||||
|
||||
val reportable: Boolean = (
|
||||
(cheatingSum >= 1 || cheatingSum + likelyCheatingSum >= 2)
|
||||
// more than 2 percent of games are cheating
|
||||
&& (cheatingSum.toDouble / assessmentsCount >= 0.02 - relationModifier
|
||||
// or more than 5 percent of games are likely cheating
|
||||
|| (cheatingSum + likelyCheatingSum).toDouble / assessmentsCount >= 0.05 - relationModifier)
|
||||
)
|
||||
|
||||
val bannable: Boolean = (relatedCheatersCount == relatedUsersCount) && relatedUsersCount >= 1
|
||||
|
||||
val actionable: Boolean = {
|
||||
def sigDif(a: Option[Int], b: Option[Int]): Option[Boolean] = (a, b) match {
|
||||
case (Some(a), Some(b)) => Some(b - a > 10)
|
||||
case _ => none
|
||||
}
|
||||
|
||||
val difs = List(
|
||||
sigDif(sfAvgBlurs, sfAvgNoBlurs),
|
||||
sigDif(sfAvgLowVar, sfAvgHighVar),
|
||||
sigDif(sfAvgHold, sfAvgNoHold)
|
||||
)
|
||||
|
||||
difs.forall(_.isEmpty) || difs.exists(~_) || assessmentsCount < 50
|
||||
}
|
||||
|
||||
if (actionable) {
|
||||
if (markable && bannable) EngineAndBan
|
||||
else if (markable) Engine
|
||||
else if (reportable) Report
|
||||
else Nothing
|
||||
} else {
|
||||
if (markable) Report
|
||||
else if (reportable) Report
|
||||
else Nothing
|
||||
}
|
||||
}
|
||||
|
||||
def countAssessmentValue(assessment: Int) = listSum(playerAssessments map {
|
||||
case a if (a.assessment == assessment) => 1
|
||||
case _ => 0
|
||||
})
|
||||
|
||||
val relatedCheatersCount = relatedCheaters.distinct.size
|
||||
val relatedUsersCount = relatedUsers.distinct.size
|
||||
val assessmentsCount = playerAssessments.size match {
|
||||
case 0 => 1
|
||||
case a => a
|
||||
}
|
||||
val relationModifier = if (relatedUsersCount >= 1) 0.02 else 0
|
||||
val cheatingSum = countAssessmentValue(5)
|
||||
val likelyCheatingSum = countAssessmentValue(4)
|
||||
|
||||
|
||||
// Some statistics
|
||||
def sfAvgGiven(predicate: PlayerAssessment => Boolean): Option[Int] = {
|
||||
val avg = listAverage(playerAssessments.filter(predicate).map(_.sfAvg)).toInt
|
||||
if (playerAssessments.exists(predicate)) Some(avg) else none
|
||||
}
|
||||
|
||||
// Average SF Avg given blur rate
|
||||
val sfAvgBlurs = sfAvgGiven(_.blurs > 70)
|
||||
val sfAvgNoBlurs = sfAvgGiven(_.blurs <= 70)
|
||||
|
||||
// Average SF Avg given move time coef of variance
|
||||
val sfAvgLowVar = sfAvgGiven((a: PlayerAssessment) => a.mtSd.toDouble / a.mtAvg < 0.5)
|
||||
val sfAvgHighVar = sfAvgGiven((a: PlayerAssessment) => a.mtSd.toDouble / a.mtAvg >= 0.5)
|
||||
|
||||
// Average SF Avg given bot
|
||||
val sfAvgHold = sfAvgGiven(_.hold)
|
||||
val sfAvgNoHold = sfAvgGiven(!_.hold)
|
||||
|
||||
def reportText(maxGames: Int = 10): String = {
|
||||
val gameLinks: String = (playerAssessments.sortBy(-_.assessment).take(maxGames).map{ a =>
|
||||
Display.emoticon(a.assessment) + " http://lichess.org/" + a.gameId + "/" + a.color.name
|
||||
}).mkString("\n")
|
||||
|
||||
s"""[AUTOREPORT]
|
||||
Cheating Games: $cheatingSum
|
||||
Likely Cheating Games: $likelyCheatingSum
|
||||
|
||||
$gameLinks"""
|
||||
}
|
||||
}
|
||||
|
||||
case class PlayerFlags(
|
||||
suspiciousErrorRate: Boolean,
|
||||
alwaysHasAdvantage: Boolean,
|
||||
highBlurRate: Boolean,
|
||||
moderateBlurRate: Boolean,
|
||||
consistentMoveTimes: Boolean,
|
||||
noFastMoves: Boolean,
|
||||
suspiciousHoldAlert: Boolean)
|
86
modules/evaluation/src/main/Statistics.scala
Normal file
86
modules/evaluation/src/main/Statistics.scala
Normal file
|
@ -0,0 +1,86 @@
|
|||
package lila.evaluation
|
||||
|
||||
import Math.{ pow, abs, sqrt, E, exp }
|
||||
import scalaz.NonEmptyList
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package lila.mod
|
||||
|
||||
import akka.actor.ActorSelection
|
||||
import lila.analyse.{ Analysis, AnalysisRepo }
|
||||
import lila.db.Types.Coll
|
||||
import lila.db.BSON.BSONJodaDateTimeHandler
|
||||
|
@ -19,6 +20,7 @@ final class AssessApi(
|
|||
collAssessments: Coll,
|
||||
logApi: ModlogApi,
|
||||
modApi: ModApi,
|
||||
reporter: ActorSelection,
|
||||
userIdsSharingIp: String => Fu[List[String]]) {
|
||||
|
||||
private implicit val playerFlagsBSONhandler = Macros.handler[PlayerFlags]
|
||||
|
@ -66,15 +68,15 @@ final class AssessApi(
|
|||
}
|
||||
|
||||
def refreshAssessByUsername(username: String): Funit = withUser(username) { user =>
|
||||
GameRepo.gamesForAssessment(user.id, 100) flatMap {
|
||||
gs => (gs map {
|
||||
(GameRepo.gamesForAssessment(user.id, 100) flatMap { gs =>
|
||||
(gs map {
|
||||
g => AnalysisRepo.doneById(g.id) flatMap {
|
||||
case Some(a) => onAnalysisReady(g, a, false)
|
||||
case _ => funit
|
||||
}
|
||||
}).sequenceFu.void
|
||||
}
|
||||
} >> assessPlayer(username)
|
||||
}) >> assessPlayer(user.id)
|
||||
}
|
||||
|
||||
def onAnalysisReady(game: Game, analysis: Analysis, assess: Boolean = true): Funit = {
|
||||
if (!game.isCorrespondence && game.turns >= 40 && game.mode.rated) {
|
||||
|
@ -88,12 +90,17 @@ final class AssessApi(
|
|||
|
||||
def assessPlayer(userId: String): Funit = getPlayerAggregateAssessment(userId) flatMap {
|
||||
case Some(playerAggregateAssessment) => playerAggregateAssessment.action match {
|
||||
case AccountAction.EngineAndBan => modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true)
|
||||
case AccountAction.Engine => modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true)
|
||||
case AccountAction.Report => funit
|
||||
case _ => funit
|
||||
case AccountAction.EngineAndBan =>
|
||||
modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true)
|
||||
case AccountAction.Engine =>
|
||||
modApi.autoAdjust(userId) >> logApi.engine("lichess", userId, true)
|
||||
case AccountAction.Report => {
|
||||
reporter ! lila.hub.actorApi.report.Cheater(userId, playerAggregateAssessment.reportText(3))
|
||||
funit
|
||||
}
|
||||
case AccountAction.Nothing => funit
|
||||
}
|
||||
case _ => funit
|
||||
case none => funit
|
||||
}
|
||||
|
||||
private def withUser[A](username: String)(op: User => Fu[A]): Fu[A] =
|
||||
|
|
|
@ -9,6 +9,7 @@ import lila.security.{ Firewall, UserSpy }
|
|||
final class Env(
|
||||
config: Config,
|
||||
db: lila.db.Env,
|
||||
hub: lila.hub.Env,
|
||||
system: ActorSystem,
|
||||
firewall: Firewall,
|
||||
userSpy: String => Fu[UserSpy],
|
||||
|
@ -41,7 +42,8 @@ final class Env(
|
|||
collAssessments = db(CollectionPlayerAssessment),
|
||||
logApi = logApi,
|
||||
modApi = api,
|
||||
userIdsSharingIp)
|
||||
reporter = hub.actor.report,
|
||||
userIdsSharingIp = userIdsSharingIp)
|
||||
|
||||
// api actor
|
||||
private val actorApi = system.actorOf(Props(new Actor {
|
||||
|
@ -63,6 +65,7 @@ object Env {
|
|||
lazy val current = "[boot] mod" describes new Env(
|
||||
config = lila.common.PlayApp loadConfig "mod",
|
||||
db = lila.db.Env.current,
|
||||
hub = lila.hub.Env.current,
|
||||
system = lila.common.PlayApp.system,
|
||||
firewall = lila.security.Env.current.firewall,
|
||||
userSpy = lila.security.Env.current.userSpy,
|
||||
|
|
|
@ -301,6 +301,25 @@ div.user_show .reportCard td {
|
|||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
div.user_show .extra_stats {
|
||||
margin: 0 auto 10px auto;
|
||||
}
|
||||
div.user_show .extra_stats caption {
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
div.user_show .extra_stats th {
|
||||
text-align: right;
|
||||
font-weight: bold;
|
||||
font-size: 1.2em;
|
||||
padding: 0 5px 0 5px;
|
||||
}
|
||||
div.user_show .extra_stats td {
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
div.user_show .results .aggregate {
|
||||
text-shadow: 1px -1px 1px #000;
|
||||
font-weight: bold;
|
||||
|
|
Loading…
Reference in a new issue