Need help understanding new WGAN

Hi,
While slowly learning DeepChem infrustructure I came across GAN examples from model/test section.
I have modified the code to more closely resemble outdated Tutorial part 16 example. Unfortunately, I am not able to obtained even closely similar results. Could you let me know if I am doing something wrong.

This is my sample code:

#GENERATE SAMPLE DATA
n_classes = 4
class_centers = np.random.uniform(-4, 4, (n_classes, 2))
class_transforms = []
for i in range(n_classes):
    xscale = np.random.uniform(0.5, 2)
    yscale = np.random.uniform(0.5, 2)
    angle = np.random.uniform(0, np.pi)
    m = [[xscale*np.cos(angle), -yscale*np.sin(angle)],
         [xscale*np.sin(angle), yscale*np.cos(angle)]]
    class_transforms.append(m)
class_transforms = np.array(class_transforms)

def generate_data(n_points):
    classes = np.random.randint(n_classes, size=n_points)
    r = np.random.random(n_points)
    angle = 2*np.pi*np.random.random(n_points)
    points = (r*np.array([np.cos(angle), np.sin(angle)])).T
    points = np.einsum('ijk,ik->ij', class_transforms[classes], points)
    points += class_centers[classes]
    return classes, points

%matplotlib inline
import matplotlib.pyplot as plot
classes, points = generate_data(10000)
plot.scatter(x=points[:,0], y=points[:,1], c=classes)
#(plot shown at the end)


#CREATE EXAMPLE WGAN CLASS
class DWGAN(dc.models.WGAN):

    def get_noise_input_shape(self):
        return (2,)

    #modified from original to contain x and y data
    def get_data_input_shapes(self):
        return [(2,)]

    #will be used for classes
    def get_conditional_input_shapes(self):
        return [(1,)]

    def create_generator(self):
        noise_input = Input(self.get_noise_input_shape())
        conditional_input = Input(self.get_conditional_input_shapes()[0])
        inputs = [noise_input, conditional_input]
        gen_in = Concatenate(axis=1)(inputs)
        
        #modified from original test example to accomodate 2d data
        output = Dense(2)(gen_in)
        return tf.keras.Model(inputs=inputs, outputs=output)

    def create_discriminator(self):
        data_input = Input(self.get_data_input_shapes()[0])
        conditional_input = Input(self.get_conditional_input_shapes()[0])
        inputs = [data_input, conditional_input]
        discrim_in = Concatenate(axis=1)(inputs)
        dense = Dense(10, activation=tf.nn.relu)(discrim_in)
        #modified from original test example to accomodate 2d data
        output = Dense(2)(dense)
        return tf.keras.Model(inputs=inputs, outputs=output)

#GENERATE INPUT DATA
def get2DData(gan):
    batch = {gan.data_inputs[0]: points, gan.conditional_inputs[0]: classes}
    yield batch

#CREATE NEW MODEL
gan = DWGAN(learning_rate=0.01, gradient_penalty=0.1, batch_size=10000)
#TRAIN MODEL
gan.fit_gan(
    get2DData(gan),
    generator_steps=0.1,
    checkpoint_interval=0)

#PREDICT NEW VALUES AND PLOT THEM
new_values = gan.predict_gan_generator(conditional_inputs=[classes])
plot.scatter(x=new_values[:,0], y=new_values[:,1], c=classes)
plot.show()

Result:
image

I have run it several times and all of them look almost identical.
I wonder if I am using the code in a wrong way and how to use properly.
I would assume that the model will try to predict x,y values for each class, with similar distribution in matching training set.
Cheers,
Milosz

1 Like

I have been playing more, trying to understand how it works behind scenes. I noticed that fit_generator is run only once per batch, therefore assumed that my previous results were due to not enough data exposure. Furthermore, I have decided to simplify everything and just remove conditional all together. I have created a set (1000 points) as before. I split it into batches (10) which then repeated (1000) and shuffled, recreating epochs functionality. Unfortunately, results did not improve and what I have obtained is almost normal distribution, suggesting that model is not learning and just outputs automatically generated noise. At least this is how I see it.

Input data (included at the end of the post):

batches = []
epochs = 1000

#split into 10 batches. points.shape = (1000,2)
for i in range(0,points.shape[0],100):
    batch = points[i:i+100]
    
    #mimic epochs
    for r in range(epochs):
        batches.append(batch)

#shuffle data
batches = np.array(batches)
np.random.shuffle(batches)
batches.shape #(10000,100,2) <- 10 batches x 1000 epochs

#Feed single batch at a time
def get2DData(gan):
    for i in range(10000):
        batch = {gan.data_inputs[0]: batches[i]}
        yield batch

#CREATE NEW MODEL
gan = DWGAN(learning_rate=0.01, gradient_penalty=0.1, batch_size=100)

#TRAIN MODEL
gan.fit_gan(
    get2DData(gan),
    generator_steps=0.1,
    checkpoint_interval=0)

#PREDICT NEW VALUES AND PLOT THEM
new_values = gan.predict_gan_generator(conditional_inputs=[classes])
plot.scatter(x=new_values[:,0], y=new_values[:,1])
plot.show()

image

After digging deeper in the gan_fit function, I have noticed that generated errors are not updating correctly:

# Train the generator.

            if generator_steps > 0.0:

                gen_train_fraction += generator_steps

                while gen_train_fraction >= 1.0:

                    inputs = [self.get_noise_batch(self.batch_size)] + inputs[1:]

                    gen_error += self.fit_generator(

                        [(inputs, [], [])],

                        variables=self.gen_variables,

                        checkpoint_interval=0)

                    gen_average_steps += 1

                    gen_train_fraction -= 1.0

            self._global_step.assign(global_step + 1)

            #added to check errors after each batch 

            print('de:{}, ge:{}'.format(discrim_error, gen_error))

This is output after I modified the code:

de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:0.0
de:nan, ge:nan
de:nan, ge:nan
de:nan, ge:nan
de:nan, ge:nan
de:nan, ge:nan
de:nan, ge:nan
de:nan, ge:nan
  • I have created a custom version of WGAN that works as expected; considering submitting it as an alternative to current infrastructure. I chose to separate it from KerasModel which required single model defined. Given that conditional WGAN-gp is quite different from your typical model I believe it to be justified. I tried to make it relatively generic and easy to modify, but it requires further work. Furthermore, by completely separating critic and generator we have flexibility to use them separately and utilise standard Keras infrastructure e.g. save, load.

  • There are different ways to introduce conditionals into the mix, I have settled for early introduction by input merging. I am also considering mult-task variant, where generator will provide two outputs (data, class). This might provide better results due to additional activation function e.g. linear for data and sigmoid for class. I welcome your opinion on the matter.

  • I will clean-up the whole code and provide jupyter notebook if interested

    class WGAN():
     """Model based on Ahmed et al. - "Improved Traning of Wasserstein GANs", https://arxiv.org/abs/1704.00028"""
      def __init__(self,
                   batch_size=100,
                   n_dimensions=2,
                   noise_dimensions=10,
                   n_critic=5,
                   gradient_penalty_weight=10,
                   plot_progress=True,
                   plot_epoch_interval=100):
          
         
          self.batch_size = batch_size
          self.gradient_penalty_weight = gradient_penalty_weight
          self.n_critic = n_critic
          self.noise_dim  = noise_dimensions
          self.dimensions = n_dimensions
          self.conditionals = 1
          
          self.plot_progress = plot_progress
          self.plot_progress_inveral= plot_epoch_interval
          
          self.discriminator_input_dim = self.dimensions + self.conditionals
          self.discriminator_output_dim = 1
          self.generator_input_dim = self.noise_dim + self.conditionals
          self.generator_output_dim = self.discriminator_input_dim
          
          
          self.generator_optimizer = self.create_generator_optimizer()
          self.discriminator_optimizer = self.create_discriminator_optimizer()
          
          self.generator = self.create_generator()
          self.discriminator = self.create_discriminator()
          
          #create sample noise, can be omitted, currently used for displaying graph
          self.noise = self.generate_noise()
          if self.conditionals >0:
              self.noise_classes = self.generate_noise_conditionals()
              self.noise = tf.concat([self.noise,self.noise_classes],axis=1)
          
    
      def create_discriminator(self):
          """
          Creates discriminator/critic infrastructure, utlises tf.Keras"""
          
          model = Sequential()
          model.add(Dense(25,  kernel_initializer='he_uniform', input_dim=self.discriminator_input_dim))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(15,  kernel_initializer='he_uniform'))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(10,  kernel_initializer='he_uniform'))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(5,  kernel_initializer='he_uniform'))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(self.discriminator_output_dim))
          return model
    
      def create_generator(self):
          """
          Creates generator, utlises tf.Keras infrastructure"""
          model = Sequential()
          model.add(Dense(30, kernel_initializer='he_uniform', input_dim=self.generator_input_dim))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(15,  kernel_initializer='he_uniform'))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(10,  kernel_initializer='he_uniform'))
          model.add(LeakyReLU(alpha=0.01))
          model.add(Dense(self.generator_output_dim))
          return model
      
      def create_generator_optimizer(self, learning_rate=1e-4):
          """Create optimizer for generator"""
          return tf.keras.optimizers.Adam(learning_rate = learning_rate)
      
      def create_discriminator_optimizer(self,learning_rate=1e-4 ):
          """Create optimizer for discriminator/critic"""
          return tf.keras.optimizers.Adam(learning_rate = learning_rate)   
      
      @tf.function
      def generate_noise(self):
          """Generate noise input for generator"""
          return tf.random.normal([self.batch_size, self.noise_dim])
      
      @tf.function
      def generate_noise_conditionals(self):
          """Generate noise conditional for generator"""
          
          #EXAMPLE CODE THAT CREATES BINARY CLASSES
          
          #create single binary class in shape format (1,batch_size)
          values = tf.random.categorical([[0.5,0.5]],self.batch_size)
          #reshape into actual input shape, has to match noise dimensions
          values =  tf.reshape(values,(self.batch_size,1))
          #cast into float so can be combined into single input with noise                    
          return tf.cast(values, dtype='float32')
      
      @tf.function
      def train_generator(self):
          """
          Method for training generator
          """
          #generate noise input data
          noise = self.generate_noise()
          
          if self.conditionals >0 :
              #generate noise conditionals
              conditions = self.generate_noise_conditionals()
              #combine into single tensor
              noise = tf.concat([noise,conditions],axis=1)
        
          with tf.GradientTape() as tape:
              #generate fake data
              fake = self.generator(noise, training=True)
              #check how well discriminator predicts fake data
              logits = self.discriminator(fake, training=True)
              loss = -tf.reduce_mean(logits)
              
          #update gradients
          gradients = tape.gradient(loss,self.generator.trainable_variables)
          self.generator_optimizer.apply_gradients(zip(gradients, self.generator.trainable_variables))
          return loss
      
      @tf.function
      def train_discriminator(self, real, real_conditionals):
          """
          Method for training discriminator aka critic
          """
          
          #check if real input is of correct shape
          assert real.shape == (self.batch_size, self.dimensions), 'Incorrect real data shape, is {} while should be {}'.format(real.shape, (self.batch_size, self.dimensions))
          
          #generate batch of random noise
          noise = self.generate_noise()
          
          #make sure that data is of type 'float32'
          if real.dtype is not 'float32':
              real =  tf.cast(real, 'float32')
          
          if self.conditionals>0:
              #generate noise conditionals
              conditionals = self.generate_noise_conditionals()
              #combine into single tensor
              noise = tf.concat([noise,conditionals],axis=1)
              
              #make sure that conditionals are also float32, otherwise tf.concat will fail
              if real_conditionals.dtype is not 'float32':
                  real_conditionals = tf.cast(real_conditionals, 'float32')
          
              #check if shape is correct, otherwise tf.concat will fail
              if real_conditionals.shape is not (self.batch_size,self.conditionals):
                  real_conditionals = tf.reshape(real_conditionals, (self.batch_size,self.conditionals))
                  
              #combine inputs
              real = tf.concat([real, real_conditionals], axis=1)
          
          with tf.GradientTape() as tape:
              fake = self.generator(noise, training=True)
              fake_logits = self.discriminator(fake, training=True)
              real_logits = self.discriminator(real, training=True)
              fake_loss = tf.reduce_mean(fake_logits)
              real_loss = tf.reduce_mean(real_logits)
              cost = fake_loss - real_loss
              penalty = self.gradient_penalty(partial(self.discriminator, training=True), real, fake)
              cost += self.gradient_penalty_weight * penalty
          gradients = tape.gradient(cost, self.discriminator.trainable_variables)
          self.discriminator_optimizer.apply_gradients(zip(gradients, self.discriminator.trainable_variables))
          return cost
      
      @tf.function
      def gradient_penalty(self,func, real, fake):
          """
          loss calculated for discriminator aka critic
          """
          alpha = tf.random.uniform([self.batch_size, 3], 0.0, 1.0)
          difference = fake - real
          internal_sample = real + (alpha * difference)
          with tf.GradientTape() as tape:
              tape.watch(internal_sample)
              predicted_sample = func(internal_sample)
          gradient = tape.gradient(predicted_sample, [internal_sample])[0]
          slopes = tf.sqrt(tf.reduce_sum(tf.square(gradient), axis=[1]))
          penalty = tf.reduce_mean((slopes - 1.)**2)
          return penalty
      
      def train(self, dataset, epochs):
          """
          Main training loop
          """
          g_train_loss = metrics.Mean()
          d_train_loss = metrics.Mean()
          
            
          for epoch in range(epochs):
              
              for _,(X,y,_,_) in enumerate(dataset.iterbatches(batch_size=self.batch_size, pad_batches=True, deterministic=True)):
    
                  for _ in range(self.n_critic):
                      
                      d_loss = self.train_discriminator(X, y)
                      d_train_loss(d_loss)
                      
                  g_loss = self.train_generator()
                  g_train_loss(g_loss)
                  self.train_generator()
              
              if  self.plot_progress == True and epoch % self.plot_progress_inveral == 0:
                  predict = self.generator(self.noise, training=False)
                  plt.scatter(points[:,0], points[:,1], c=classes,alpha=0.2)
                  plt.scatter(predict[:,0], predict[:,1], c= [tf.math.round(x) for x in predict[:,2]], label='Epoch:{}'.format(epoch))
                  plt.legend(bbox_to_anchor=(0.7, 1.0), loc='upper left')
                  plt.savefig('images/image_at_epoch_{:04d}.png'.format(epoch))
                  
                  display.clear_output(wait=True)
                  display.display(plt.show(), 'Epoch:{}'.format(epoch))
                  
                  
              g_train_loss.reset_states()
              d_train_loss.reset_states()
    
      wgan = WGAN(plot_epoch_interval=10)
      dataset = dc.data.NumpyDataset(points,classes)
      wgan.train(dataset,10000)
    

This is example training, depending on the run it reaches good results between 500-10000 epochs (depending on difficulty of starting set). It is trained on 1000 samples, batches of 100.

Imgur

1 Like

Love the animation here! CC’ing in @peastman who might be interested in the discussion.

As a first comment, it would be very useful if we can instead extend KerasModel to this new use case. Making KerasModel more useful for GANs seems like a very useful extension of the infrastructure. Perhaps we can make the critic and generator into tf.keras.Model objects which we use within a KerasModel?

It would not be a problem to create a single model out of critic and generator (some people prefer it that way). Effectively you create critic and generator/critic, and set-up critic in 2nd model to Training=False. But, I find it less flexible and GAN is simply two models that cooperate. I wonder if forcing it to be KerasModel is worth loosing its flexibility. I will look into that and see if can come up with something flexible that utilises single model e.g. treating generator as the model and critic as sub-model (without combining them).

Link to Jupyter Notebook

1 Like

Hi,
I have created new account for my own convenience, sorry for the confusion.

I have created expanded version of previous WGAN that is more inline with DeepChem paradigm (utilising KerasModel). You can access Jupyter Notebook here.

This is work in progress and would welcome input what else should be included.

Some details:

  • Main model is generator, but this is just a placeholder to comply with KerasModel

  • Save and Load methods create critic and generator independently and save/load them from two separate folder.

  • Optimizers are saved along with models and are updated upon loading

  • Save checkpoints is included, but did no merge it with fit, hence user will have to manually use it. I will probably integrate it a bit more in future releases (to be more inline with DeepChem)

  • Tensorboard is not yet included (need to figure-out how to utilise it)

I think there are to ways to approach GAN:

  • Single model - critic and generator/critic, where main model is generator/critic, this might simplifies architecture (relies more on KerasModel) but will results in less flexibility and will introduce issues if someone wanted just load critic or generator.
  • Two models - the current version, that needs a expanding a bit, provides more flexibility and user just needs to specify two models. Downside, the architecture relies less on KerasModel. Given that GAN are mainly used for generating new data, we can treat generator as main model and critic as sub-model.

What are your thoughts?

1 Like

I’m really sorry I haven’t had time to reply to this yet. Things have been pretty busy. I’ll try to go over the code as soon as I can!

1 Like

No worries, everybody is busy.
If you have any questions let me know.
FYI, I have included 4 ways of showing progress, bar, history plot, data 3d plot and history + 3d data. I will remove 3d versions later in order to make the code cleaner and more generic. I am thinking about leaving verbose 3, so user will be able to specify there own progress function. I think something like this would be also useful is KerasModel.

1 Like

I’m just starting to go through this. The first thing I did was convert the conditional GAN example to use the GAN class. Here is my code.

from tensorflow.keras.layers import Concatenate, Dense, Input

class ExampleGAN(dc.models.GAN):

  def get_noise_input_shape(self):
    return (10,)

  def get_data_input_shapes(self):
    return [(2,)]

  def get_conditional_input_shapes(self):
    return [(n_classes,)]

  def create_generator(self):
    noise_in = Input(shape=(10,))
    conditional_in = Input(shape=(n_classes,))
    gen_in = Concatenate()([noise_in, conditional_in])
    gen_dense1 = Dense(30, activation=tf.nn.relu)(gen_in)
    gen_dense2 = Dense(30, activation=tf.nn.relu)(gen_dense1)
    generator_points = Dense(2)(gen_dense2)
    return tf.keras.Model(inputs=[noise_in, conditional_in], outputs=[generator_points])

  def create_discriminator(self):
    data_in = Input(shape=(2,))
    conditional_in = Input(shape=(n_classes,))
    discrim_in = Concatenate()([data_in, conditional_in])
    discrim_dense1 = Dense(30, activation=tf.nn.relu)(discrim_in)
    discrim_dense2 = Dense(30, activation=tf.nn.relu)(discrim_dense1)
    discrim_prob = Dense(1, activation=tf.sigmoid)(discrim_dense2)
    return tf.keras.Model(inputs=[data_in, conditional_in], outputs=[discrim_prob])

gan = ExampleGAN(learning_rate=1e-4)

def iterbatches(batches):
  for i in range(batches):
    classes, points = generate_data(gan.batch_size)
    classes = dc.metrics.to_one_hot(classes, n_classes)
    yield {gan.data_inputs[0]: points, gan.conditional_inputs[0]: classes}

gan.fit_gan(iterbatches(2000))

classes, points = generate_data(1000)
one_hot_classes = dc.metrics.to_one_hot(classes, n_classes)
gen_points = gan.predict_gan_generator(conditional_inputs=[one_hot_classes])
plot.scatter(x=gen_points[:,0], y=gen_points[:,1], c=classes)

That works perfectly. Here is the original data distribution:

image

And here is the generated data:

image

1 Like

Converting it to a WGAN also works fine:

image

The code is almost identical:

from tensorflow.keras.layers import Concatenate, Dense, Input

class ExampleGAN(dc.models.WGAN):

  def get_noise_input_shape(self):
    return (10,)

  def get_data_input_shapes(self):
    return [(2,)]

  def get_conditional_input_shapes(self):
    return [(n_classes,)]

  def create_generator(self):
    noise_in = Input(shape=(10,))
    conditional_in = Input(shape=(n_classes,))
    gen_in = Concatenate()([noise_in, conditional_in])
    gen_dense1 = Dense(30, activation=tf.nn.relu)(gen_in)
    gen_dense2 = Dense(30, activation=tf.nn.relu)(gen_dense1)
    generator_points = Dense(2)(gen_dense2)
    return tf.keras.Model(inputs=[noise_in, conditional_in], outputs=[generator_points])

  def create_discriminator(self):
    data_in = Input(shape=(2,))
    conditional_in = Input(shape=(n_classes,))
    discrim_in = Concatenate()([data_in, conditional_in])
    discrim_dense1 = Dense(30, activation=tf.nn.relu)(discrim_in)
    discrim_dense2 = Dense(30, activation=tf.nn.relu)(discrim_dense1)
    discrim_dist = Dense(1, activation=tf.math.softplus)(discrim_dense2)
    return tf.keras.Model(inputs=[data_in, conditional_in], outputs=[discrim_dist])

gan = ExampleGAN(learning_rate=1e-4)

def iterbatches(batches):
  for i in range(batches):
    classes, points = generate_data(gan.batch_size)
    classes = dc.metrics.to_one_hot(classes, n_classes)
    yield {gan.data_inputs[0]: points, gan.conditional_inputs[0]: classes}

gan.fit_gan(iterbatches(5000), generator_steps=0.2)
2 Likes

It would not be a problem to create a single model out of critic and generator (some people prefer it that way). Effectively you create critic and generator/critic, and set-up critic in 2nd model to Training=False. But, I find it less flexible and GAN is simply two models that cooperate. I wonder if forcing it to be KerasModel is worth loosing its flexibility.

Keras lets you compose models. The generator and discriminator are each an independent model. For purposes of training them, it creates a compound model that incorporates both of them, but you can still use the component models on their own. In what way is this less flexible?

2 Likes

Hi,
I was not able to make it work, but I see where I went wrong with it (iterbatches).
So I suppose, I can scrap my code (at least majority, save load option still should be useful).

Regarding less flexibility, could you show an example of compound model?
I used to build ensembles and I think I know what you mean, but just want to be sure that I am not missing any useful functionality.

As I understand it, compound model is effectively a single model that combines generator and critic in linear fashion. Given that critic has to trained more times than generator, one has to set-up training to false for generator in generator/critic combo. I suppose one can just swap this variable off/on depending which one is trained. Maybe it is less flexible as I thought, just different way looking at it.

Each generator and discriminator (there could be more than one if you’re using MIX+GAN) is a separate tf.keras.Model. You can access them through gan.generators and gan.discriminators.

Internally it creates a larger model that invokes all the component generators and discriminators, computes appropriate loss functions, etc. When you call fit_gan() it makes a series of calls to KerasModel functions to fit that larger model, with different calls specifying different variables and loss functions depending on which part is being fit at that moment. Users shouldn’t need to worry about that though. They just call fit_gan() to perform fitting, and when it’s done they can directly access the trained generators and discriminators. Although even that isn’t usually necessary: for most purposes you just call predict_gan_generator() to make predictions.

2 Likes

I rewrote the tutorial to use the GAN class. The updated version is at https://github.com/deepchem/deepchem/blob/master/examples/tutorials/16_Conditional_Generative_Adversarial_Networks.ipynb

2 Likes

Hi Peter,
Thanks for the updated version, looks nice and clear.
I have question regarding saving the model.
As I see it, currently there are three options for saving/loading GAN.

  • Create custom method that utilises native Keras model save, then load it by loading the model and setting GAN’s KerasModel model to loaded model
  • Load model using load_from_pretrained method.
  • Using restore method and checkpoint

But what happens with optimizer?
I do not see models being compiled, therefore native save function will not save optimizer.

1 Like

The checkpoints saved by KerasModel include optimizer parameters. Take a look in KerasModel._ensure_built() where it defines the checkpoint:

    self._checkpoint = tf.train.Checkpoint(
        optimizer=self._tf_optimizer, model=self.model)
2 Likes

Hi Peter,
Perfect, thank you for taking your time and explaining this.

On a related note, do you know if someone is working on implementation of something similar.
I have spoke with Bharath regarding graph to RDKit molecule conversion some time ago, which would allow similar GraphConv GAN in DeepChem. I am asking as this is of high interest to me and if no one is looking into it I will try.

1 Like

I’m not aware of anyone working on that. If you’d like to give it a try, that would be fantastic!

1 Like

OK, will do that. I have already started looking at converting graphs back to molecules. I will let you know once have have something useful.

1 Like