Collect more lag statistics

Fancy mathematical stats monitoring
Stats.scala has been moved to scalachess
lagStats
Isaac Levy 2018-02-14 17:54:44 -05:00
parent 43d37f1094
commit 94332d246f
7 changed files with 44 additions and 176 deletions

View File

@ -1,77 +0,0 @@
package lila.common
// Welford's numerically stable online variance.
//
sealed trait Stats {
def samples: Int
def mean: Float
def variance: Option[Float]
def record(value: Float): Stats
def +(o: Stats): Stats
def record[T](values: Traversable[T])(implicit n: Numeric[T]): Stats =
values.foldLeft(this) { (s, v) => s record n.toFloat(v) }
def stdDev = variance.map { Math.sqrt(_).toFloat }
}
protected final case class StatHolder(
samples: Int,
mean: Float,
sn: Float
) extends Stats {
def variance = (samples > 1) option (sn / (samples - 1))
def record(value: Float) = {
val newSamples = samples + 1
val delta = value - mean
val newMean = mean + delta / newSamples
val newSN = sn + delta * (value - newMean)
StatHolder(
samples = newSamples,
mean = newMean,
sn = newSN
)
}
def +(o: Stats) = o match {
case EmptyStats => this
case StatHolder(oSamples, oMean, oSN) => {
val invTotal = 1f / (samples + oSamples)
val combMean = {
if (samples == oSamples) (mean + oMean) * 0.5f
else (mean * samples + oMean * oSamples) * invTotal
}
val meanDiff = mean - oMean
StatHolder(
samples = samples + oSamples,
mean = combMean,
sn = sn + oSN + meanDiff * meanDiff * samples * oSamples * invTotal
)
}
}
}
protected object EmptyStats extends Stats {
val samples = 0
val mean = 0f
val variance = None
def record(value: Float) = StatHolder(
samples = 1,
mean = value,
sn = 0f
)
def +(o: Stats) = o
}
object Stats {
val empty = EmptyStats
def apply(value: Float) = empty.record(value)
def apply[T: Numeric](values: Traversable[T]) = empty.record(values)
}

View File

@ -163,12 +163,14 @@ object mon {
def create = makeTrace("round.move.trace")
}
object lag {
val avgReported = rec("round.move.lag.avg_reported")
private val estErrorRec = rec("round.move.lag.estimate_error_1000")
def estimateError(e: Int) = estErrorRec(e + 1000)
val compDeviation = rec("round.move.lag.comp_deviation")
def uncomped(key: String) = rec(s"round.move.lag.uncomped.$key")
val uncompedAll = rec(s"round.move.lag.uncomped.all")
val stdDev = rec(s"round.move.lag.stddev_ms")
val mean = rec(s"round.move.lag.mean_ms")
val coefVar = rec(s"round.move.lag.coef_var_1000")
val compEstStdErr = rec(s"round.move.lag.comp_est_stderr_1000")
val compEstOverErr = rec("round.move.lag.avg_over_error_ms")
}
}
object error {

View File

@ -1,68 +0,0 @@
package lila.common
import org.specs2.mutable.Specification
import org.specs2.matcher.ValidationMatchers
class StatsTest extends Specification with ValidationMatchers {
def realMean(elts: Seq[Float]): Float = elts.sum / elts.size
def realVar(elts: Seq[Float]): Float = {
val mean = realMean(elts).toDouble
(elts map { x => Math.pow(x - mean, 2) } sum).toFloat / (elts.size - 1)
}
def beApprox(comp: Float) = (f: Float) => {
if (comp.isNaN) f.isNaN must beTrue
else comp must beCloseTo(f +/- 0.001f * comp)
}
def beLike(comp: Stats) = (s: Stats) => {
s.samples must_== comp.samples
s.mean must beApprox(comp.mean)
(s.variance, comp.variance) match {
case (Some(sv), Some(cv)) => sv must beApprox(cv)
case (sv, cv) => sv must_== cv
}
}
"empty stats" should {
"have good defaults" in {
Stats.empty.variance must_== None
Stats.empty.mean must_== 0f
Stats.empty.samples must_== 0
}
"convert to StatHolder" in {
Stats(5) must beLike(StatHolder(0, 0f, 0f).record(5))
"with good stats" in {
Stats(5).samples must_== 1
Stats(5).variance must_== None
Stats(5).mean must_== 5f
}
}
}
"large values" should {
// Tight data w/ large mean. Shuffled for Stats.
val base = (1 to 100) ++ (1 to 100) ++ (1 to 200)
val data = base map { _ + 1e5f }
val shuffledData = base.sortWith(_ % 8 > _ % 8) map { _ + 1e5f }
val statsN = Stats.empty record shuffledData
"match actuals" in {
statsN.mean must beApprox(realMean(data))
statsN.variance.get must beApprox(realVar(data))
statsN.samples must_== 400
}
"match concat" in {
statsN must_== (Stats.empty + statsN)
statsN must_== (statsN + Stats.empty)
statsN must beLike(Stats(data take 1) + Stats(data drop 1))
statsN must beLike(Stats(data take 100) + Stats(data drop 100))
statsN must beLike(Stats(data take 200) + Stats(data drop 200))
}
}
}

View File

@ -1,7 +1,7 @@
package lila.evaluation
import chess.Centis
import lila.common.{ Maths, Stats }
import chess.{ Centis, Stats }
import lila.common.Maths
object Statistics {

View File

@ -1,6 +1,6 @@
package lila.round
import chess.{ Status, Color }
import chess.{ Status, DecayingStats, Color, Clock }
import lila.game.actorApi.{ FinishGame, AbortedBy }
import lila.game.{ GameRepo, Game, Pov, RatingDiffs }
@ -59,6 +59,33 @@ private[round] final class Finisher(
)(implicit proxy: GameProxy): Fu[Events] =
apply(game, status, winner, message) >>- playban.other(game, status, winner)
private def recordLagStats(game: Game): Unit = for {
clock <- game.clock
player <- clock.players.all
lt = player.lag
stats = lt.lagStats
moves = lt.moves if moves > 4
sd <- stats.stdDev
mean = stats.mean if mean > 0
uncomp <- lt.totalUncomped / moves
compEstStdErr <- lt.compEstStdErr
quotaStr = f"${lt.quotaGain.centis / 10}%02d"
compEstOvers = lt.compEstOvers.centis
} {
import lila.mon.round.move.{ lag => lRec }
lRec.mean(Math.round(10 * mean))
lRec.stdDev(Math.round(10 * sd))
// wikipedia.org/wiki/Coefficient_of_variation#Estimation
lRec.coefVar(Math.round((1000f + 250f / moves) * sd / mean))
lRec.uncomped(quotaStr)(uncomp.centis)
lRec.uncompedAll(uncomp.centis)
lt.lagEstimator match {
case h: DecayingStats => lRec.compDeviation(h.deviation.toInt)
}
lRec.compEstStdErr(Math.round(1000 * compEstStdErr))
lRec.compEstOverErr(Math.round(10f * compEstOvers / moves))
}
private def apply(
game: Game,
makeStatus: Status.type => Status,
@ -70,6 +97,7 @@ private[round] final class Finisher(
if (game.nonAi && game.isCorrespondence) Color.all foreach notifier.gameEnd(prog.game)
lila.mon.game.finish(status.name)()
val g = prog.game
recordLagStats(g)
proxy.save(prog) >>
GameRepo.finish(
id = g.id,
@ -92,6 +120,7 @@ private[round] final class Finisher(
message foreach { messenger.system(g, _) }
GameRepo game g.id foreach { newGame =>
bus.publish(finish.copy(game = newGame | g), 'finishGame)
}
prog.events :+ lila.game.Event.EndData(g, ratingDiffs)
}

View File

@ -6,7 +6,7 @@ import org.joda.time.DateTime
import scala.concurrent.duration._
import actorApi._, round._
import chess.{ DecayingStats, Color }
import chess.Color
import lila.game.{ Game, Progress, Pov, Event }
import lila.hub.actorApi.DeployPost
import lila.hub.actorApi.map._
@ -60,7 +60,7 @@ private[round] final class Round(
handleHumanPlay(p) { pov =>
if (pov.game.outoftime(withGrace = true)) finisher.outOfTime(pov.game)
else {
recordLagStats(pov)
recordLag(pov)
player.human(p, self)(pov)
}
} >>- {
@ -240,33 +240,15 @@ private[round] final class Round(
(game withClock newClock) ++ colors.map { Event.ClockInc(_, centis) }
}
private def recordLagStats(pov: Pov) =
private def recordLag(pov: Pov) =
if ((pov.game.playedTurns & 30) == 10) {
// Triggers every 32 moves starting on ply 10.
// i.e. 10, 11, 42, 43, 74, 75, ...
for {
user <- pov.player.userId
clock <- pov.game.clock
lt = clock.lag(pov.color)
lag <- lt.avgLag
} {
pov.player.userId.foreach { UserLagCache.put(_, lag) }
if (pov.game.playedTurns < 12) {
import lila.mon.round.move.{ lag => lRec }
lRec.avgReported(lag.centis)
lt.history match {
case h: DecayingStats => lRec.compDeviation(h.deviation.toInt)
}
for {
lowEst <- lt.lowEstimate
avgComp <- lt.avgLagComp
uncomp <- (lt.totalLag - lt.totalComp) / lt.lagSteps
} {
lRec.estimateError((avgComp - lowEst).centis)
lRec.uncomped(f"${lt.quotaGain.centis / 10}%02d")(uncomp.centis)
lRec.uncompedAll(uncomp.centis)
}
}
}
lag <- clock.lag(pov.color).lagMean
} UserLagCache.put(user, lag)
}
private def scheduleExpiration: Funit = proxy.game map {

View File

@ -28,7 +28,7 @@ object Dependencies {
val findbugs = "com.google.code.findbugs" % "jsr305" % "3.0.1"
val hasher = "com.roundeights" %% "hasher" % "1.2.0"
val jodaTime = "joda-time" % "joda-time" % "2.9.9"
val chess = "org.lichess" %% "scalachess" % "8.4"
val chess = "org.lichess" %% "scalachess" % "8.6"
val compression = "org.lichess" %% "compression" % "1.2"
val maxmind = "com.sanoma.cda" %% "maxmind-geoip2-scala" % "1.2.3-THIB"
val prismic = "io.prismic" %% "scala-kit" % "1.2.11-THIB"