Implementando uma camada de função Bump generalizada treinável no Keras / Tensorflow

8

Estou tentando codificar a seguinte variante da função Bump , aplicada em componentes:

equação da função de colisão generalizada,

onde σ é treinável; mas não está funcionando (erros relatados abaixo).


Minha tentativa:

Aqui está o que eu codifiquei até agora (se ajudar). Suponha que eu tenha duas funções (por exemplo):

  def f_True(x):
    # Compute Bump Function
    bump_value = 1-tf.math.pow(x,2)
    bump_value = -tf.math.pow(bump_value,-1)
    bump_value = tf.math.exp(bump_value)
    return(bump_value)

  def f_False(x):
    # Compute Bump Function
    x_out = 0*x
    return(x_out)

class trainable_bump_layer(tf.keras.layers.Layer):

    def __init__(self, *args, **kwargs):
        super(trainable_bump_layer, self).__init__(*args, **kwargs)

    def build(self, input_shape):
        self.threshold_level = self.add_weight(name='threshlevel',
                                    shape=[1],
                                    initializer='GlorotUniform',
                                    trainable=True)

    def call(self, input):
        # Determine Thresholding Logic
        The_Logic = tf.math.less(input,self.threshold_level)
        # Apply Logic
        output_step_3 = tf.cond(The_Logic, 
                                lambda: f_True(input),
                                lambda: f_False(input))
        return output_step_3

Relatório de erro:

    Train on 100 samples
Epoch 1/10
WARNING:tensorflow:Gradients do not exist for variables ['reconfiguration_unit_steps_3_3/threshlevel:0'] when minimizing the loss.
WARNING:tensorflow:Gradients do not exist for variables ['reconfiguration_unit_steps_3_3/threshlevel:0'] when minimizing the loss.
 32/100 [========>.....................] - ETA: 3s

...

tensorflow:Gradients do not exist for variables 

Além disso, ele não parece ser aplicado em termos de componentes (além do problema não treinável). Qual poderia ser o problema?

FlabbyTheKatsu
fonte
qual é a dimensão do input? é um escalar?
Vlad
1
Olá @ProbablyAHuman, você pode fornecer um código reproduzível mínimo para o seu cenário e especificar como exatamente ele não está funcionando?
TF_Support 30/03
1
@TF_Support Adicionei detalhes do meu objetivo e do relatório de erros ...
FlabbyTheKatsu 30/03
O sigma é treinável?
Daniel Möller
Você poderia compartilhar o gráfico do que deseja e do que pode variar neste gráfico?
Daniel Möller

Respostas:

2

Estou um pouco surpreso que ninguém tenha mencionado a principal (e única) razão para o aviso dado! Como parece, esse código deve implementar a variante generalizada da função Bump; no entanto, basta dar uma olhada nas funções implementadas novamente:

def f_True(x):
    # Compute Bump Function
    bump_value = 1-tf.math.pow(x,2)
    bump_value = -tf.math.pow(bump_value,-1)
    bump_value = tf.math.exp(bump_value)
    return(bump_value)

def f_False(x):
    # Compute Bump Function
    x_out = 0*x
    return(x_out)

O erro é evidente: não há uso do peso treinável da camada nessas funções! Portanto, não é de surpreender que você receba a mensagem dizendo que não existe gradiente para isso: você não o está usando, portanto, não há gradiente para atualizá-lo! Pelo contrário, esta é exatamente a função Bump original (ou seja, sem peso treinável).

Mas, você pode dizer que: "pelo menos, usei o peso treinável na condição de tf.cond, então deve haver alguns gradientes ?!"; no entanto, não é assim e deixe-me esclarecer a confusão:

  • Primeiro de tudo, como você também notou, estamos interessados ​​no condicionamento por elementos. Então, em vez de tf.condvocê precisar usar tf.where.

  • O outro equívoco é afirmar que, uma vez que tf.lessé usado como condição, e como não é diferenciável, ou seja, não possui gradiente em relação a suas entradas (o que é verdade: não há gradiente definido para uma função com saída booleana, que é real. entradas valiosas!), então isso resulta no aviso dado!

    • Isso é simplesmente errado! A derivada aqui seria retirada da saída do peso treinável da camada e a condição de seleção NÃO está presente na saída. Pelo contrário, é apenas um tensor booleano que determina o ramo de saída a ser selecionado. É isso aí! A derivada da condição não é aceita e nunca será necessária. Portanto, esse não é o motivo do aviso dado; a razão é única e apenas o que mencionei acima: nenhuma contribuição do peso treinável na saída da camada. (Nota: se o ponto sobre a condição é um pouco surpreendente para você, pense em um exemplo simples: a função ReLU, que é definida como relu(x) = 0 if x < 0 else x. Se a derivada da condição, ou seja,x < 0, é considerado / necessário, o que não existe, então não poderíamos usar ReLU em nossos modelos e treiná-los usando métodos de otimização baseados em gradiente!)

(Nota: a partir daqui, eu me referiria e denotaria o valor do limiar como sigma , como na equação).

Tudo certo! Encontramos a razão por trás do erro na implementação. Podemos consertar isso? Claro! Aqui está a implementação de trabalho atualizada:

import tensorflow as tf
from tensorflow.keras.initializers import RandomUniform
from tensorflow.keras.constraints import NonNeg

class BumpLayer(tf.keras.layers.Layer):
    def __init__(self, *args, **kwargs):
        super(BumpLayer, self).__init__(*args, **kwargs)

    def build(self, input_shape):
        self.sigma = self.add_weight(
            name='sigma',
            shape=[1],
            initializer=RandomUniform(minval=0.0, maxval=0.1),
            trainable=True,
            constraint=tf.keras.constraints.NonNeg()
        )
        super().build(input_shape)

    def bump_function(self, x):
        return tf.math.exp(-self.sigma / (self.sigma - tf.math.pow(x, 2)))

    def call(self, inputs):
        greater = tf.math.greater(inputs, -self.sigma)
        less = tf.math.less(inputs, self.sigma)
        condition = tf.logical_and(greater, less)

        output = tf.where(
            condition, 
            self.bump_function(inputs),
            0.0
        )
        return output

Alguns pontos em relação a esta implementação:

  • Nós substituímos tf.condpor tf.wherepara fazer o condicionamento por elementos.

  • Além disso, como você pode ver, ao contrário de sua implementação que só verificado para um dos lados da desigualdade, estamos usando tf.math.less, tf.math.greatere também tf.logical_andpara descobrir se os valores de entrada têm magnitudes inferior a sigma(alternativamente, poderíamos fazer isso usando apenas tf.math.abse tf.math.less, sem diferença !). E vamos repetir: usar funções de saída booleana dessa maneira não causa problemas e não tem nada a ver com derivadas / gradientes.

  • Também estamos usando uma restrição de não negatividade no valor sigma aprendido por camada. Por quê? Como os valores de sigma menores que zero não fazem sentido (ou seja, o intervalo (-sigma, sigma)é mal definido quando o sigma é negativo).

  • E considerando o ponto anterior, tomamos o cuidado de inicializar o valor sigma corretamente (ou seja, para um pequeno valor não negativo).

  • E também, por favor, não faça coisas como 0.0 * inputs! É redundante (e um pouco estranho) e é equivalente a 0.0; e ambos têm um gradiente de 0.0(wrt inputs). A multiplicação de zero por um tensor não adiciona nada ou resolve qualquer problema existente, pelo menos não neste caso!

Agora, vamos testá-lo para ver como funciona. Escrevemos algumas funções auxiliares para gerar dados de treinamento com base em um valor sigma fixo e também para criar um modelo que contém um único BumpLayercom formato de entrada de (1,). Vamos ver se ele pode aprender o valor sigma usado para gerar dados de treinamento:

import numpy as np

def generate_data(sigma, min_x=-1, max_x=1, shape=(100000,1)):
    assert sigma >= 0, 'Sigma should be non-negative!'
    x = np.random.uniform(min_x, max_x, size=shape)
    xp2 = np.power(x, 2)
    condition = np.logical_and(x < sigma, x > -sigma)
    y = np.where(condition, np.exp(-sigma / (sigma - xp2)), 0.0)
    dy = np.where(condition, xp2 * y / np.power((sigma - xp2), 2), 0)
    return x, y, dy

def make_model(input_shape=(1,)):
    model = tf.keras.Sequential()
    model.add(BumpLayer(input_shape=input_shape))

    model.compile(loss='mse', optimizer='adam')
    return model

# Generate training data using a fixed sigma value.
sigma = 0.5
x, y, _ = generate_data(sigma=sigma, min_x=-0.1, max_x=0.1)

model = make_model()

# Store initial value of sigma, so that it could be compared after training.
sigma_before = model.layers[0].get_weights()[0][0]

model.fit(x, y, epochs=5)

print('Sigma before training:', sigma_before)
print('Sigma after training:', model.layers[0].get_weights()[0][0])
print('Sigma used for generating data:', sigma)

# Sigma before training: 0.08271004
# Sigma after training: 0.5000002
# Sigma used for generating data: 0.5

Sim, ele pode aprender o valor do sigma usado para gerar dados! Mas, é garantido que ele realmente funciona para todos os diferentes valores de dados de treinamento e inicialização do sigma? A resposta é não! Na verdade, é possível que você execute o código acima e obtenha nano valor da sigma após o treinamento ou info valor da perda! Então qual é o problema? Por que isso nanou infvalores podem ser produzidos? Vamos discutir abaixo ...


Lidar com a estabilidade numérica

Uma das coisas importantes a considerar, ao construir um modelo de aprendizado de máquina e usar métodos de otimização baseados em gradiente para treiná-los, é a estabilidade numérica das operações e cálculos em um modelo. Quando valores extremamente grandes ou pequenos são gerados por uma operação ou seu gradiente, quase certamente isso atrapalha o processo de treinamento (por exemplo, essa é uma das razões por trás da normalização dos valores de pixel de imagem nas CNNs para evitar esse problema).

Então, vamos dar uma olhada nessa função de bump generalizada (e vamos descartar o limiar por enquanto). É óbvio que esta função possui singularidades (isto é, pontos em que a função ou seu gradiente não está definido) em x^2 = sigma(isto é, quando x = sqrt(sigma)ou x=-sqrt(sigma)). O diagrama animado abaixo mostra a função bump (a linha vermelha sólida), sua derivada wrt sigma (a linha verde pontilhada) e x=sigmaand x=-sigmalines (duas linhas verticais tracejadas em azul), quando o sigma começa do zero e é aumentado para 5:

Função de resposta generalizada quando o sigma começa do zero e é aumentado para cinco.

Como você pode ver, em torno da região das singularidades, a função não é bem-comportada para todos os valores de sigma, no sentido de que a função e sua derivada assumem valores extremamente grandes nessas regiões. Assim, dado um valor de entrada nessas regiões para um valor específico de sigma, seriam gerados valores explosivos de saída e gradiente, daí a questão do infvalor da perda.

Além disso, há um comportamento problemático tf.whereque causa a emissão de nanvalores para a variável sigma na camada: surpreendentemente, se o valor produzido no ramo inativo de tf.wherefor extremamente grande ou infque, com a função bump, resulta em valores extremamente grandes ou infgradientes , então o gradiente de tf.whereseria nan, apesar do fato de infestar no ramo inativo e nem ser selecionado (consulte esta edição do Github que discute exatamente isso) !!

Portanto, existe alguma solução alternativa para esse comportamento de tf.where? Sim, na verdade, há um truque para resolver de alguma forma esse problema, explicado nesta resposta : basicamente podemos usar um adicional tf.wherepara impedir que a função seja aplicada nessas regiões. Em outras palavras, em vez de aplicar self.bump_functionem qualquer valor de entrada, filtramos os valores que NÃO estão no intervalo (-self.sigma, self.sigma)(ou seja, o intervalo real em que a função deve ser aplicada) e, em vez disso, alimentamos a função com zero (que sempre produz valores seguros, ou seja, é igual a exp(-1)):

     output = tf.where(
            condition, 
            self.bump_function(tf.where(condition, inputs, 0.0)),
            0.0
     )

A aplicação dessa correção resolveria completamente a questão dos nanvalores para o sigma. Vamos avaliá-lo nos valores dos dados de treinamento gerados com diferentes valores sigma e ver como ele funcionaria:

true_learned_sigma = []
for s in np.arange(0.1, 10.0, 0.1):
    model = make_model()
    x, y, dy = generate_data(sigma=s, shape=(100000,1))
    model.fit(x, y, epochs=3 if s < 1 else (5 if s < 5 else 10), verbose=False)
    sigma = model.layers[0].get_weights()[0][0]
    true_learned_sigma.append([s, sigma])
    print(s, sigma)

# Check if the learned values of sigma
# are actually close to true values of sigma, for all the experiments.
res = np.array(true_learned_sigma)
print(np.allclose(res[:,0], res[:,1], atol=1e-2))
# True

Poderia aprender todos os valores sigma corretamente! Isso é bom. Essa solução funcionou! Embora exista uma ressalva: é garantido que funcione corretamente e aprenda qualquer valor sigma se os valores de entrada para essa camada forem maiores que -1 e menores que 1 (ou seja, este é o caso padrão de nossa generate_datafunção); caso contrário, ainda existe a questão do infvalor da perda que pode ocorrer se os valores de entrada tiverem uma magnitude maior que 1 (consulte os pontos 1 e 2 abaixo).


Aqui estão alguns pensamentos para os curiosos e a mente interessada:

  1. Acabamos de mencionar que, se os valores de entrada para essa camada forem maiores que 1 ou menores que -1, isso poderá causar problemas. Você pode argumentar por que esse é o caso? (Dica: use o diagrama animado acima e considere os casos em que sigma > 1e o valor de entrada está entre sqrt(sigma)e sigma(ou entre -sigmae -sqrt(sigma).)

  2. Você pode fornecer uma correção para o problema no ponto 1, ou seja, para que a camada funcione para todos os valores de entrada? (Dica: como a solução alternativa tf.where, pense em como você pode filtrar ainda mais os valores não seguros nos quais a função bump pode ser aplicada e produzir saída / gradiente explosivos.)

  3. No entanto, se você não está interessado em corrigir esse problema e gostaria de usar essa camada em um modelo como está agora, como garantir que os valores de entrada nessa camada estejam sempre entre -1 e 1? (Dica: como uma solução, existe uma função de ativação comumente usada que produz valores exatamente nesse intervalo e pode ser potencialmente usada como a função de ativação da camada anterior a essa camada.)

  4. Se você der uma olhada no último trecho de código, verá que usamos epochs=3 if s < 1 else (5 if s < 5 else 10). Por que é que? Por que grandes valores de sigma precisam de mais épocas para serem aprendidas? (Dica: novamente, use o diagrama animado e considere a derivada da função para valores de entrada entre -1 e 1 à medida que o valor sigma aumenta. Qual é a magnitude deles?)

  5. Você também precisa verificar os dados de treinamento gerados para qualquer nan, infou extremamente grandes valores de ye filtrá-los? (Dica: sim, se sigma > 1e faixa de valores, ou seja, min_xe max_x, cair fora do (-1, 1)!?!, Caso contrário, não que não é necessário Por que isso é deixado como um exercício)

hoje
fonte
1
Bom trabalho. @ProbablyAHuman, essa deve ser a resposta aceita.
rvinas 19/04
@hoje. Acho que isso é ótimo, possivelmente a resposta mais detalhada / precisa / agressiva que eu já vi em qualquer pilha. Muito obrigado!
FlabbyTheKatsu 20/04
4

Infelizmente, nenhuma operação para verificar se xestá dentro (-σ, σ)será diferenciável e, portanto, σ não pode ser aprendido através de nenhum método de descida de gradiente. Especificamente, não é possível calcular os gradientes em relação a, self.threshold_levelporque tf.math.lessnão é diferenciável em relação à condição.

Em relação à condicional em termos de elementos, você pode usar tf.where para selecionar elementos de f_True(input)ou de f_False(input)acordo com os valores booleanos em termos de componentes da condição. Por exemplo:

output_step_3 = tf.where(The_Logic, f_True(input), f_False(input))

NOTA: Respondi com base no código fornecido, onde self.threshold_levelnão é usado f_Truenem f_False. Se self.threshold_levelfor usada nessas funções como na fórmula fornecida, a função será, naturalmente, diferenciável em relação a self.threshold_level.

Atualizado em 19/04/2020: obrigado hoje pelo esclarecimento .

rvinas
fonte
Deve haver um truque de codificação para torná-lo treinável ...
FlabbyTheKatsu
Receio que nenhum truque de implementação mágica o torne treinável se a matemática não funcionar ...
rvinas 31/03
"A mensagem de erro está indicando precisamente isso - não é possível calcular os gradientes em relação ao self.threshold_level porque tf.math.less não é diferenciável em relação às suas entradas." -> A mensagem de aviso não tem nada a ver com o uso tf.math.lessna condição e o fato de não ser diferenciável. A condição não precisa ser diferenciável para fazer esse trabalho. O erro está no fato de que o peso treinável não é usado para produzir a saída da camada (ou seja, não há vestígios dele na saída). Por favor, veja a primeira parte da minha resposta para saber mais sobre isso.
hoje
Concordo, não é isso que a mensagem de aviso está dizendo e vou corrigir minha redação. No entanto, o ponto permanece o mesmo - você não pode ter uma operação para verificar se uma variável está dentro de um intervalo específico e espera que seja diferenciável em relação à variável limite. Dito isto, se essa variável for usada para o cálculo da saída (que eu nem percebi na fórmula, devo admitir), ela terá, é claro, gradientes.
rvinas 19/04
3

Eu sugiro que você tente uma distribuição normal em vez de um solavanco. Nos meus testes aqui, essa função de bump não está se comportando bem (não consigo encontrar um bug, mas não o descarto, mas meu gráfico mostra dois solavancos muito acentuados, o que não é bom para redes)

Com uma distribuição normal, você obteria um solavanco regular e diferenciável cuja altura, largura e centro você pode controlar.

Então, você pode tentar esta função:

y = a * exp ( - b * (x - c)²)

Experimente em algum gráfico e veja como ele se comporta.

Por esta:

class trainable_bump_layer(tf.keras.layers.Layer):

    def __init__(self, *args, **kwargs):
        super(trainable_bump_layer, self).__init__(*args, **kwargs)

    def build(self, input_shape):

        #suggested shape (has a different kernel for each input feature/channel)
        shape = tuple(1 for _ in input_shape[:-1]) + input_shape[-1:]

        #for your desired shape of only 1:
        shape = tuple(1 for _ in input_shape) #all ones

        #height
        self.kernel_a = self.add_weight(name='kernel_a ',
                                    shape=shape
                                    initializer='ones',
                                    trainable=True)

        #inverse width
        self.kernel_b = self.add_weight(name='kernel_b',
                                    shape=shape
                                    initializer='ones',
                                    trainable=True)

        #center
        self.kernel_c = self.add_weight(name='kernel_c',
                                    shape=shape
                                    initializer='zeros',
                                    trainable=True)

    def call(self, input):
        exp_arg = - self.kernel_b * K.square(input - self.kernel_c)
        return self.kernel_a * K.exp(exp_arg)
Daniel Möller
fonte