Является ли Gen.pick ScalaCheck действительно случайным?

Я наблюдал следующее неожиданное поведение при использовании ScalaCheck Gen.pic, что (для меня) указывает на то, что его выбор не совсем случайный, хотя его документация говорит так:

/** A generator that picks a given number of elements from a list, randomly */

Я запустил следующие три маленькие программы по порядку (в течение 2 дней, в разное время, если это может иметь значение) после установки

implicit override val generatorDrivenConfig = PropertyCheckConfig(
  maxSize = 1000, 
  minSize = 1000, 
  minSuccessful = 1000)

чтобы получить приличный размер выборки.

Программа №1

val set = Set(1,2,3,4,5,6,7,8,9,10,
      11,12,13,14,15,16,17,18,19,20,
      21,22,23,24,25,26,27,28,29,30,
      31,32,33,34,35,36,37,38,39,40,
      41,42,43,44,45,46,47,48,49,50)

// Thanks to @Jubobs for the solution
// See: http://stackoverflow.com/a/43825913/4169924
val g = Gen.pick(3, set).map { _.toList }
forAll (g) { s => println(s) }

Из 3000 чисел, сгенерированных в 2 разных прогонах, я получил удивительно похожее и довольно неслучайное распределение (числа округлены, перечислены только первые 5, как и во всем списке здесь и далее):

  • Число: частота в прогоне №1, частота в прогоне №2.
  • 15: 33%, 33%
  • 47: 22%, 22%
  • 4: 15 %, 16 %
  • 19: 10%, 10%
  • 30: 6%, 6%

(Отказ от ответственности: я не смог найти здесь, как создать таблицу, кроме вот так)

Программа 2

val list: List[Int] = List.range(1, 50)
val g = Gen.pick(3, list)
forAll (g) { s => println(s) }

В случае использования List числа "застревают" в конце диапазона (3x1000 чисел в случае обоих прогонов):

  • 49: 33%, 33%
  • 48: 22%, 22%
  • 47: 14%, 14%
  • 46: 10 %, 10 %
  • 45: 6%, 6%

Интересно, что частоты почти такие же, как и в случае программы 1.

Примечание: я повторил прогоны для списков до 10 раз и получил точно такое же распределение с различиями +/- 1%, просто не хотел перечислять все числа здесь, в этой странной "таблице". "формат.

Программа 3

Просто чтобы немного оживить ситуацию, я запустил третий небольшой фрагмент, создав Set (Программа 1) из List (Программа 2):

val set: Set[Int] = List.range(1, 50).toSet
val g = Gen.pick(3, set).map { _.toList }
forAll (g) { s => println(s) }

Теперь числа те же, что и для Программы 2 (List побед!), хотя частоты (опять же, для 3*1000 номеров за 2 прогона) в конце немного изменились:

  • 49: 33%, 33%
  • 48: 23%, 22%
  • 47: 16 %, 15 %
  • 46: 9 %, 10 %
  • 45: 7 %, 6 %

Вопрос

Несмотря на то, что размер выборки недостаточен (как никогда не бывает достаточно), чтобы определить истинную случайность, я не могу не поставить под сомнение заявленную случайность Gen.pick (насколько это возможно в готовом виде). , мне может понадобиться установить начальное число, чтобы оно работало «больше» случайным образом), поскольку числа «застряли», а частоты почти одинаковы.

После просмотра исходного кода Gen.pick< /a>, в строке #672 используется некий seed0:

def pick[T](n: Int, l: Iterable[T]): Gen[Seq[T]] = {
    if (n > l.size || n < 0) throw new IllegalArgumentException(s"invalid choice: $n")
    else if (n == 0) Gen.const(Nil)
    else gen { (p, seed0) =>
    // ...

который я не могу найти нигде (в Исходный код Gen.scala или в scala.util.Random), но у меня есть подозрение, что это может иметь какое-то отношение к наблюдаемому поведению. Это ожидаемое поведение Gen.pick? Если да, то как я могу получить «больше» случайного выбора?


person bugfoot    schedule 08.05.2017    source источник
comment
Bugfoot, Не уверен, что тебя это все еще волнует, но я думаю, что диагноз @ashawley неверен, и на самом деле это просто ошибка. Смотрите мой ответ для некоторых деталей   -  person SergGr    schedule 09.05.2017
comment
Ваш ответ теперь принят как ответ, спасибо, что сделали все возможное.   -  person bugfoot    schedule 09.05.2017


Ответы (2)


Хотя ответ @ashawley уже принят, я не думаю, что он правильный. Я думаю, что на самом деле это ошибка, и она была введена коммитом erik-stripe на 1 сентября 2016 г. и ошибка действительно в строке

      val i = (x & 0x7fffffff).toInt % n

и это должно было быть

      val i = (x & 0x7fffffff).toInt % count

что еще не совсем правильно.

Я также ожидаю, что ваши 33% для последнего значения на самом деле 100%, и вы не учли тот факт, что вы выбрали 3 элемента, поэтому вся ваша статистика должна быть умножена на 3. Итак, для 3 -element selection последний элемент выбирается 100%, предыдущий - 66,6% и так далее, что даже хуже, чем вы ожидали.

Вот еще раз выдержка из кода:

else gen { (p, seed0) =>
  val buf = ArrayBuffer.empty[T]
  val it = l.iterator
  var seed = seed0
  var count = 0
  while (it.hasNext) {
    val t = it.next
    count += 1
    if (count <= n) {
      buf += t
    } else {
      val (x, s) = seed.long
      val i = (x & 0x7fffffff).toInt % n
      if (i < n) buf(i) = t
      seed = s
    }
  }
  r(Some(buf), seed)
}

Так что же этот код должен делать и что он делает на самом деле? Ветка if (count <= n) заполняет вывод buf первыми n элементами, после чего всегда работает ветвь else. Чтобы было понятнее, я изменил while перемещение if наружу на следующий код:

  for (i <- 0 until  n) {
    val t = it.next
    buf += t
  }
  while (it.hasNext) {
    val t = it.next
    val (x, s) = seed.long
    val i = (x & 0x7fffffff).toInt % n
    if (i < n) buf(i) = t
    seed = s
  }

Итак, теперь становится очевидным, что ветвь else должна одновременно решить, следует ли добавить текущий элемент в вывод buf И какой элемент там он должен заменить. Очевидно, что текущий код всегда выбирает каждый элемент, потому что if (i < n) всегда истинно, учитывая, что i вычисляется как something % n. И именно поэтому вы видите такой огромный перекос в последних элементах.

Очевидно, планировалось использовать модифицированную версию перемешивания Фишера-Йейтса. который выбирает только первые n элементов перетасовки, и чтобы сделать это правильно, вам нужно выбрать случайные числа в диапазоне [0, count) и, вероятно, поэтому код написан так, как он написан, т.е. сохраняет counter внутри while цикла.

Использование % count по-прежнему не совсем правильно, потому что такой простой способ не дает равномерного распределения, когда count не является степенью двойки. Чтобы быть более справедливым, что-то вроде

    val c0 = choose(0, count-1)
    val rt: R[Int] = c0.doApply(p, seed)        
    seed = rt.seed      
    val i = rt.retrieve.get // index to swap current element with. Should be fair random number in range [0, count-1], see Fisher–Yates shuffle
    if (i < n) buf(i) = t

или какой-либо другой способ создать i как справедливое равномерно распределенное случайное число в таком диапазоне.

Обновить (почему просто % count неправильно)

Вы можете посмотреть java.util.Random.nextInt(int) реализация или org.scalacheck.Choose.chLng для примера того, как это должно быть сделано. Это сложнее, чем просто % count, и на это есть веская причина. Чтобы проиллюстрировать это, рассмотрим следующий пример. Предположим, что ваш исходный генератор случайных чисел генерирует равномерно случайные 3-битные значения, то есть в диапазоне только [0, 7], и вы хотите получить случайное число в диапазоне [0, 2], и вы делаете это, просто выполнив

srcGenerator.nextInt() % 3

Теперь рассмотрим сопоставление значений в диапазоне [0, 7] с вашим диапазоном [0, 2]:

  • 0, 3, 6 будет сопоставлено с 0 (т.е. сопоставляются 3 значения)
  • 1, 4, 7 будет сопоставлено с 1 (т.е. сопоставляются 3 значения)
  • 2, 5 будет сопоставлено с 2 (т.е. сопоставлены только 2 значения)

Итак, если вы сделаете только % 3, ваше распределение будет 0 - 3/8, 1 – 3/8, 2 – 2/8, что явно неравномерно. Вот почему те реализации, на которые я ссылался ранее, используют своего рода цикл и отбрасывают некоторые значения, сгенерированные генератором исходного кода. Требуется произвести однородную раздачу.

person SergGr    schedule 08.05.2017
comment
Принято как ответ @SergGr, так как это исчерпывающее объяснение с предложением решения. У меня не было достаточно репутации, чтобы явно вызвать слово на букву «б» :-) - person bugfoot; 09.05.2017
comment
Я связал вас и ваш пост в соответствующем выпуске GitHub @ github.com/rickynils/scalacheck/issues/ 332, если вы разрешите @SergGr - person bugfoot; 09.05.2017
comment
Разве это не просто переформулирует проблему, а затем повторно реализует решение той же проблемы? - person ashawley; 09.05.2017
comment
@ashawley, я не уверен, что вы имели в виду, но на самом деле мой последний фрагмент кода (предложение) содержал ошибку с неправильным использованием метода choose, которую я заметил только после ваших комментариев. Я все еще не уверен, что мой последний фрагмент кода верен, потому что я не уверен, что пользуюсь внутренними структурами данных ScalaCheck, но если вы генерируете i на каждом шаге как справедливое случайное число в диапазоне [0, count - 1], я ожидаю, что это решение сгенерирует совершенно случайное распределение. Если вы все еще видите некоторые ошибки, пожалуйста, дайте мне знать. - person SergGr; 09.05.2017
comment
Проблема в том, что для равномерного распределения вам нужно знать длину коллекции, но, как следует из моего ответа, это невозможно без использования итерации. - person ashawley; 09.05.2017
comment
@ashawley, я не думаю, что ты правильно прочитал код. pick полностью использует итерацию при каждой попытке создать следующее случайное подмножество. Это то, что делают while (it.hasNext) { val t = it.next ... строки. На самом деле трудно представить, как можно иметь жесткий перекос в конец итерируемого объекта и не использовать его полностью. - person SergGr; 09.05.2017
comment
Нет, оказывается (хотя это и не очевидно сразу), что длину знать не обязательно. Это известно как отбор проб из резервуара, см. gregable.com/2007/10/reservoir-sampling. html или en.wikipedia.org/wiki/Reservoir_sampling. - person Alexey Romanov; 09.05.2017
comment
Да, это решение, которое я пытался понять в своей голове. Спасибо за ссылку. - person ashawley; 09.05.2017
comment
@ashawley, но это (алгоритм R из вики) - это именно то, что я ожидаю от первоначального намерения этого кода и что предлагает мой последний фрагмент кода. - person SergGr; 09.05.2017
comment
Хорошо, тогда вы должны отредактировать свои ответы и показать этот код. Я этого не вижу. - person ashawley; 10.05.2017
comment
@ashawley, ну, мой последний фрагмент кода — это то, что вы должны поместить в ветвь else цикла while вместо строки, содержащей ошибку, которую я поместил в первый фрагмент кода. Я не уверен, что еще вы ожидаете. - person SergGr; 10.05.2017
comment
Выглядит сложнее, чем должно быть. Вы читали статьи о пробоотборе резервуаров? - person ashawley; 10.05.2017
comment
@ashawley, единственная причина, по которой это сложно, заключается в том, что трудно сгенерировать random(1, i) (или, скорее, random(0, i-1), поскольку массивы Scala основаны на 0) способом ScalaCheck. Это то, что делают первые 4 из 5 строк кода (моя i — это j из wki, моя count — это i из вики, а моя n — это k из вики)! Вы видели, что на самом деле требует ScalaCheck gen? - person SergGr; 10.05.2017
comment
Я считаю, что он должен использовать % count, что вы сначала говорите, но затем отбрасываете. - person ashawley; 10.05.2017
comment
@ashawley, нет % count - это улучшение, но не правильное решение, если вы хотите получить равномерное случайное распределение. Смотрите мое обновление для примера, почему это неправильно. - person SergGr; 10.05.2017
comment
Я не уверен, почему ты так думаешь. Вы либо неправильно понимаете, что такое count в коде, либо не знаете, что делает по модулю. - person ashawley; 10.05.2017
comment
@ashawley, вы уверены, что понимаете пример в моем обновлении Почему только % count неверно в ответе? Алгоритм R из вики требует random(1, i). Вам не кажется, по крайней мере, подозрительным, что известные библиотеки не реализуют такой метод с помощью одного простого %, а используют для этого более сложный код? - person SergGr; 10.05.2017
comment
Нет, это не подозрительно. Просто красиво. Он генерирует значение из [0, Int.MaxValue). - person ashawley; 10.05.2017
comment
@ashawley, просто красиво, если правильно! Вы смотрите на nextInt(int), который сложно создать [0, requestedMaximum-1], или просто nextInt(), который прост и генерирует [0, Int.MaxValue]. Требуется хитрость, чтобы сопоставить [0, Int.MaxValue] с [0, requestedMaximum-1], чтобы оно по-прежнему оставалось равномерно случайным - person SergGr; 10.05.2017

Не думаю, что это связано с семенами. Это имеет прямое отношение к эвристике Скалачека.

Есть тонкий баг. Подумайте, что он делает. Он заставляет себя выбирать значения в начале, а затем случайным образом перезаписывать их после этого:

while (it.hasNext) {
  val t = it.next
  count += 1
  if (count <= n) {
    buf += t
  } else {
    val (x, s) = seed.long
    val i = (x & 0x7fffffff).toInt % n
    if (i < n) buf(i) = t
    seed = s
  }
  ...

Он случайным образом присваивает эти элементы результату в else-блоке, поэтому отдает предпочтение хвостовым значениям.

Итак, pick случайным образом выбирает значения из набора. Однако он пожертвовал равнозначным выбором значений и отдает предпочтение концу списка, потому что код пытается лениво перебирать список.

Чтобы попытаться получить равномерное распределение выбранных элементов, вам нужно знать длину коллекции, но, как предполагает мой ответ, это невозможно без двойного использования итерации.

Возможно, если вы выберете reverse в своем списке или shuffle, вы получите лучшее распределение выборов с pick.

Поскольку Scalacheck — это универсальная библиотека для тестирования свойств, я предполагаю, что она не может делать ни одну из этих вещей, не жертвуя производительностью для коллекций произвольного размера.

Обновлять

Но, как указывает Алексей Романов, это должно реализовать алгоритм отбора проб из резервуара, который позволяет избежать знания длины и может выполняться за время O(n). Просто дефект в коде. Исправление просто исправляет строку для генерации случайных чисел. Он должен получить случайное число от 1 до k-го (count) элемента, посещенного в списке.

val i = (x & 0x7fffffff).toInt % n

Должно быть:

val i = (x & 0x7fffffff).toInt % count

Я отправил PR в Scalacheck:

https://github.com/rickynils/scalacheck/pull/333

person ashawley    schedule 08.05.2017
comment
Вы абсолютно правы, что это из-за откладывания сбора до конца списка. Тем не менее я не могу назвать конечный результат случайным выбором... Я создал задачу на GitHub ScalaCheck (github.com/rickynils/scalacheck/issues/332), цитируя и ссылаясь на вас, если вы позволите. - person bugfoot; 08.05.2017
comment
Вы упомянули слово на букву «б», вы были на правильном пути, и это заслуга, но @SergGr получил полное объяснение, поэтому мне пришлось принять его как ответ. - person bugfoot; 09.05.2017
comment
Ну, я никогда не думал, что заслуживаю ответа. Я думаю, что вы выбрали преждевременно. - person ashawley; 09.05.2017