Как могут отличаться эти альтернативные конструкции `uniform` и `random`?

У меня был код, который случайным образом инициализировал некоторые массивы numpy с помощью:

rng = np.random.default_rng(seed=seed)
new_vectors = rng.uniform(-1.0, 1.0, target_shape).astype(np.float32)  # [-1.0, 1.0)
new_vectors /= vector_size

И все работало хорошо, все тесты проекта проходили.

К сожалению, uniform() возвращает np.float64, хотя последующие шаги требуют только np.float32, а в некоторых случаях этот массив очень велик (подумайте о миллионах 400-мерных векторов слов). Таким образом, временное возвращаемое значение np.float64 на мгновение использует в 3 раза больше необходимой оперативной памяти.

Таким образом, я заменил вышеизложенное тем, что по определению должно быть эквивалентно:

rng = np.random.default_rng(seed=seed)
new_vectors = rng.random(target_shape, dtype=np.float32)  # [0.0, 1.0)                                                 
new_vectors *= 2.0  # [0.0, 2.0)                                                                                  
new_vectors -= 1.0  # [-1.0, 1.0)
new_vectors /= vector_size

И после этого изменения все тесно связанные функциональные тесты по-прежнему проходят, но один удаленный, второстепенный тест, основанный на далеко идущих вычислениях на основе векторов, инициализированных таким образом, начал давать сбой. И провал очень надежным способом. Это стохастический тест, который проходит с большой погрешностью в верхнем регистре, но всегда дает сбой в нижнем регистре. Итак: что-то изменилось, но каким-то очень тонким образом.

Поверхностные значения new_vectors кажутся правильными и одинаково распределенными в обоих случаях. И опять же, все тесты функциональности крупным планом все равно проходят.

Так что мне бы хотелось, чтобы теории о том, какие неинтуитивные изменения могли быть сделаны этим изменением в 3 строки, которые могли бы проявиться далеко вниз по течению.

(Я все еще пытаюсь найти минимальный тест, который обнаруживает все отличия. Если вам нравится глубоко погрузиться в затронутый проект, увидеть точные тесты крупным планом, которые успешны, и один дополнительный тест, который терпит неудачу, и фиксирует с /без небольшого изменения, на https://github.com/RaRe-Technologies/gensim/pull/2944#issuecomment-704512389, Но на самом деле, я просто надеюсь, что эксперт по numpy может распознать какой-нибудь крошечный угловой случай, когда происходит что-то неинтуитивное, или предложить некоторые проверяемые теории того же самого. )

Любые идеи, предлагаемые тесты или возможные решения?


person gojomo    schedule 06.10.2020    source источник
comment
Я не эксперт по numpy, а numpy noob, но я подозреваю, что в первой версии будет еще несколько бит. Попробуйте это   -  person Kelly Bundy    schedule 07.10.2020
comment
Это очень интересная демонстрация некоторой разницы, которая сохраняется, даже если массивы внешне одинаковы dtype с похожими значениями. Спасибо! Вы видите мои дальнейшие изменения на этом repl.it? Почему float32, приведенный вниз из float64, сохраняет больше младших битов? (Кроме того, я не уверен, что какие-либо из моих нижестоящих вычислений должны быть чувствительны к этим крошечным битам, чтобы без них работать хуже, но как ощутимую разницу между двумя кодовыми путями я буду продолжать изучать. Возможная причина.)   -  person gojomo    schedule 07.10.2020
comment
(Вот моя слегка измененная версия: repl.it/repls/PowerfulFavorablePi - я могу копаться в numpy источник, прежде чем это будет сделано. Наблюдение ниже, что один путь генерирует только все остальные числа, интересно. Я полагаю, что преобразование необработанных битов в число с правильным диапазоном усекает немного больше битов, от 32, чем от 64. Может быть?)   -  person gojomo    schedule 07.10.2020
comment
Я не думаю, что семена или что-то еще играют роль. Это просто дает вам разные числа одного и того же качества, а не другого качества. Почему не float32 из float64 сохраняет больше битов? Он может сохранять 24 значащих бита, а у float64 гораздо больше. Допустим, вы производите float64 0.000000000011111111111111111111 (в двоичном формате). float32-rng никогда не смог бы это сделать, но float64-rng может, и преобразование в float32 сохраняет все это.   -  person Kelly Bundy    schedule 07.10.2020
comment
Я уверен, что вы на что-то. Но основной источник случайных битов одинаков в обоих случаях. И, очевидно, в интерпретации есть некоторое гигантское совпадение, учитывая, что числа с плавающей запятой в позициях (0,1,2..) потока float64, за исключением их младших битов, равны числам с плавающей запятой в позициях (1,3 ,5...) поколения float32. Кажется, очень первые 32 бита из генератора становятся полными float32 в случае float32, но только младшими битами в случае float64 (чувствительно для меня). Но тогда даже преобразование числа float64 вниз к 32 битам сохраняет некоторые из этих других 32 бит (что немного удивительно).   -  person gojomo    schedule 07.10.2020
comment
В любом случае, я надеюсь, что это не причина провала моего теста, потому что я хочу иметь возможность полагаться на случайные числа, которые когда-либо были только float32, а не вынуждены генерировать 64-битные случайные числа только для того, чтобы получить float32 этого достаточно.   -  person gojomo    schedule 07.10.2020
comment
Сохранение большего количества битов не должно удивлять. И дело в том, что в наших демонстрациях все числа, полученные вашим первым способом, которые не заканчиваются как .0, не могут быть получены с помощью float32-rng. Если бы это было возможно, это было бы предвзято.   -  person Kelly Bundy    schedule 07.10.2020
comment
Не знаю, откуда следует этот «предвзятый» вывод. Не могли бы какие-либо возможные значения, отличные от .0, появиться таким образом, чтобы сохранить единообразие? А если нет, то следует ли из этого, что простой акт взятия «единообразных» значений float64 и понижения их до float32 разрушает их единообразие? В таком случае: что, по нашему мнению, будет наиболее однородным — rng.uniform(0.0, 1.0, (1)), rng.uniform(0.0, 1.0, (1)).astype(np.float32), rng.random(1) или rng.random(1, dtype=np.float32)? (Я надеюсь, что на практике мои потребители будут устойчивы к небольшим различиям, но ваш комментарий о предвзятости вызывает у меня любопытство.)   -  person gojomo    schedule 07.10.2020
comment
Спасибо за ответ с полной информацией о взаимодействии точности/однородности здесь. В конечном итоге оказалось, что мой нисходящий тест не был чувствителен к младшим значащим битам, а скорее был недостаточно устойчив к seed вариантам. Случайный альтернативный поток float32 только что переключил seed с удачливого на неудачный для удаленного теста. И тот факт, что я все еще видел дрожание в пределе прохождения при повторных запусках из-за другой случайности в тесте, поначалу не позволял мне понять основную роль семени.   -  person gojomo    schedule 09.10.2020


Ответы (3)


Я запустил ваш код со следующими значениями:

seed = 0
target_shape = [100]
vector_size = 3

Я заметил, что код в вашем первом решении сгенерировал новые_векторы, отличные от вашего второго решения.

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

В следующем фрагменте я вставил только пробелы, чтобы выровнять похожие значения. вероятно, также происходит некоторое округление с плавающей запятой, из-за чего результат кажется не идентичным.

[            0.09130779,              -0.15347552,             -0.30601767,              -0.32231492,              0.20884682, ...]
[0.23374946, 0.09130772, 0.007424275, -0.1534756, -0.12811375, -0.30601773, -0.28317323, -0.32231498, -0.21648853, 0.20884681, ...]

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

person steviestickman    schedule 06.10.2020
comment
Это отличные наблюдения, спасибо! (В том числе то, как downcast фактически отбрасывает половину необработанных случайных битов.) И да, неудачный тест имеет некоторое постоянное заполнение, но есть и другие источники не засеянной случайности в каждом прогоне, поэтому повторные прогоны показывают некоторое ожидаемое дрожание — хотя Случай «неудачи» всегда находится далеко за пределами допуска, а случай «успеха» всегда внутри. Теперь я попробовал несколько альтернативных семян в случае «успеха» и не могу заставить их потерпеть неудачу, но среди нескольких альтернативных семян в случае «неудачи» пара преуспела. Итак, теперь мы углубимся во влияние семян. - person gojomo; 07.10.2020
comment
Хотя я узнал больше из других ответов, которые очень хорошо реагировали на мой запрос о том, что может вызвать такую ​​​​разницу, оказалось, что это была проблема с (ненадежным) тестом с использованием seed, которому повезло для float64производного потока и не повезло для float32 потока. Испытание ряда семян выявило около 20-30% неудач в любом случае. - person gojomo; 09.10.2020

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

Например:

def generate(shape, value, *, seed=None, step=10):
  arr = np.empty(shape, dtype=np.float32)
  rng = np.random.default_rng(seed=seed)
  (d0, *dr) = shape
  for i in range(0, d0, step):
    j = min(d0, i + step)
    arr[i:j,:] = rng.uniform(-1/value, 1/value, size=[j-i]+dr)
  return arr

который можно использовать как:

generate((100, 1024, 1024), 7, seed=13)

Вы можете настроить размер этих блоков (через step) для поддержания производительности.

person Sam Mason    schedule 07.10.2020
comment
Очень хорошо. У меня была та же основная идея (заполнить куски), но я, как нуб, не пытался ее реализовать :-). Как вы думаете, стоит ли поддерживать эту точность, учитывая, что она больше поддерживается для чисел, близких к нулю? В своем ответе я возражаю против этого, и генераторы numpy также этого не делают (хотя это может быть для эффективности, поскольку я полагаю, что правильно взвешенное распределение будет более сложным и медленным). - person Kelly Bundy; 07.10.2020
comment
думаю, это зависит от того, насколько OP заботится о своих LSB. им нужно быть очень осторожными с тем, что они делают, чтобы это имело значение, но может быть! Я думаю, что ваш ответ правильный о том, что происходит, но я изо всех сил пытаюсь понять объяснение. (prng.di.unimi.it содержит полезную информацию о создании плавающих чисел полной точности в конце документ) - person Sam Mason; 07.10.2020

Выведем new_vectors * 2**22 % 1 для обоих методов, т.е. посмотрим, что осталось после первых 22 дробных бит (программа в конце). С первым методом:

[[0.         0.5        0.25       0.         0.        ]
 [0.5        0.875      0.25       0.         0.25      ]
 [0.         0.25       0.         0.5        0.5       ]
 [0.6875     0.328125   0.75       0.5        0.52539062]
 [0.75       0.75       0.25       0.375      0.25      ]]

Со вторым методом:

[[0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]
 [0. 0. 0. 0. 0.]]

Большая разница! Второй метод не создает чисел с 1 битами после первых 22 дробных битов.

Давайте представим, что у нас есть тип float3, который может содержать только три значащих бита (подумайте о диапазоне ненулевых битов), например числа (в двоичном формате) 1,01, 11100,0 или 0,0000111, но не 10,01, потому что имеет четыре значащих бита.

Затем генератор случайных чисел для диапазона [0, 1) выберет из этих восьми чисел:

0.000
0.001
0.010
0.011
0.100
0.101
0.110
0.111

Сейчас подожди. Почему только из этих восьми? Как насчет, например, вышеупомянутого 0,0000111? Это в [0, 1) и может быть представлено, верно?

Ну да, но обратите внимание, что это в [0, 0,5). Кроме того, нет дополнительных представленных чисел в диапазоне [0,5, 1), так как все эти числа начинаются с 0,1, и, следовательно, любые дальнейшие 1-биты могут быть только во втором или третьем дробном бите. Например, число 0,1001 невозможно представить, так как оно содержит четыре значащих бита.

Таким образом, если бы генератор также выбирал из любых других чисел, кроме тех восьми, что указаны выше, все они должны были бы находиться в диапазоне [0, 0,5], что создавало бы смещение. Вместо этого он может выбрать из разных четырех чисел в этом диапазоне или, возможно, включить все представимые числа в этом диапазоне с соответствующими вероятностями, но в любом случае у вас будет смещение зазора, где числа, выбранные из [0, 0,5), могут иметь меньшие или большие пробелы, чем числа, выбранные из [0,5, 1). Не уверен, что смещение разрыва — это вещь или правильный термин, но дело в том, что распределение в [0, 0,5) будет выглядеть иначе, чем в [0,5, 1). Единственный способ сделать так, чтобы они выглядели одинаково, — это выбрать из этих восьми чисел, расположенных на одинаковом расстоянии друг от друга выше. Распределение/возможности в [0.5, 1) определяют, что вы должны использовать в [0, 0.5).

Итак... генератор случайных чисел для float3 выберет из этих восьми чисел и никогда не сгенерирует, например, 0,0000111. А теперь представьте, что у нас также есть тип float5, который может содержать пять значащих битов. Затем генератор случайных чисел для этого может выбрать 0,00001. И если вы затем преобразуете это в наше float3, это сохранится, у вас будет 0,00001 как float3. Но в диапазоне [0,5, 1) этот процесс генерации float5 чисел и преобразования их в float3 по-прежнему будет давать только числа 0,100, 0,101, 0,110 и 0,111, поскольку float3 по-прежнему не может представлять никакие другие числа в этом диапазоне.

Вот что вы получите, только с float32 и float64. Ваши два метода дают вам разные дистрибутивы. Я бы сказал, что распределение второго метода на самом деле лучше, так как у первого метода есть то, что я назвал смещением разрыва. Так что, возможно, сломался не ваш новый метод, а тест. Если это так, исправьте тест. В противном случае идея исправить вашу ситуацию может состоять в том, чтобы использовать старый способ float64-to-float32, но не производить все сразу. Вместо этого подготовьте структуру float32, используя везде только 0.0, а затем заполните ее меньшими фрагментами, сгенерированными по-новому.

Небольшое предостережение, кстати: похоже, в NumPy есть ошибка для генерации случайных значений float32, не используя самый младший бит. Так что это может быть еще одной причиной провала теста. Вы можете попробовать второй метод с (rng.integers(0, 2**24, target_shape) / 2**24).astype(np.float32) вместо rng.random(target_shape, dtype=np.float32). Я думаю, что это эквивалентно тому, что было бы в исправленной версии (поскольку, по-видимому, в настоящее время это делается именно так, за исключением 23 вместо 24).

Программа для эксперимента вверху (также на repl.it):

import numpy as np

# Some setup
seed = 13
target_shape = (5, 5)
vector_size = 1

# First way
rng = np.random.default_rng(seed=seed)
new_vectors = rng.uniform(-1.0, 1.0, target_shape).astype(np.float32)  # [-1.0, 1.0)
new_vectors /= vector_size

print(new_vectors * 2**22 % 1)

# Second way
rng = np.random.default_rng(seed=seed)
new_vectors = rng.random(target_shape, dtype=np.float32)  # [0.0, 1.0)                                                 
new_vectors *= 2.0  # [0.0, 2.0)                                                                                  
new_vectors -= 1.0  # [-1.0, 1.0)
new_vectors /= vector_size

print(new_vectors * 2**22 % 1)
person Kelly Bundy    schedule 07.10.2020
comment
Спасибо, это впечатляющее объяснение и примеры того, почему генератор более низкой точности не будет использовать все биты. И кажется, что любое небольшое смещение от понижения более высокой точности относится примерно к тому же классу, что и числа с плавающей запятой, не могут представлять все числа, и только в порядке крошечных отклонений с наименьшим возможным приращением, а не с большим смещением, которое при использовании будет создано больше младших битов в генераторе изначально более низкой точности. Спасибо! - person gojomo; 07.10.2020