pqcprep.training_tools

Collection of useful functions for network training purposes.

  1"""
  2Collection of useful functions for network training purposes.
  3"""
  4
  5import numpy as np 
  6from qiskit_machine_learning.neural_networks import SamplerQNN
  7from qiskit_machine_learning.connectors import TorchConnector
  8from qiskit_algorithms.utils import algorithm_globals 
  9from qiskit.primitives import Sampler
 10from torch.optim import Adam
 11from torch import Tensor, no_grad
 12import sys, time, os, warnings, torch  
 13
 14from .binary_tools import bin_to_dec, dec_to_bin  
 15from .phase_tools import full_encode, phase_from_state
 16from .pqc_tools import generate_network, binary_to_encode_param, A_generate_network, get_state_vec  
 17from .file_tools import compress_args,compress_args_ampl, vars_to_name_str, vars_to_name_str_ampl 
 18from .psi_tools import psi, A 
 19
 20#---------------------------------------------------------------------------------------------------
 21
 22def set_loss_func(loss_str, arg_dict, ampl=False):
 23    r"""
 24    Set the loss function to be used in training a network. 
 25
 26    Arguments:
 27    ----
 28
 29    - **loss_str** : *str*
 30
 31        String specifying the loss function to use. Options are 
 32
 33        - `'MSE'` : mean squared error loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.MSELoss.html) with default settings. 
 34          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output. 
 35
 36        - `'L1'` : mean absolute error loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.L1Loss.html) with default settings. 
 37          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.    
 38
 39        - `'KLD'` : Kullback-Leibler divergence loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.KLDivLoss.html) with default settings. 
 40          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.   
 41
 42        - `'CE'` : cross entropy loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html) with default settings. 
 43          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.  
 44
 45        - `'SAM'` : sign-adjusted mismatch. Defined as $$\text{SAM}(\ket{x}, \ket{y}) =  1 - \sum_k |x_k| |y_k|,$$ where $\ket{x}$, $\ket{y}$ are the 
 46            network output and desired output, respectively, and $x_k$, $y_k$ are the coefficients w.r.t the two-register computational basis states.  `criterion` takes 
 47            two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.       
 48
 49        - `'WIM'` : weighted mismatch. Defined analogously to SAM but with additional weights: $$\text{WIM}(\ket{x}, \ket{y}) =1 - \sum_k w_k |x_k||y_k|.$$
 50           `criterion` takes three pytorch `Tensors` as inputs, corresponding to the network output, the desired output and the weights. See `set_WIM_weights()`
 51           for information on how the weights are calculated. This loss function is not an option if `ampl` is True. 
 52
 53        - `'WILL'` : weighted Lp loss. Defined as $$\text{WILL}(\ket{x}, \ket{y}; p,q) = \sum_k |x_k -y_k|^p + |x_k| |[k]_m - \Psi([k]_n)|^q,$$ where $p$ and $q$ are coefficients stored in 
 54           `arg_dict['WILL_p']`, `arg_dict['WILL_q']`, $[k]_n$ is the target register bit-string associated with the basis state $\ket{k}$, $[k]_n$ is the input register bit-string 
 55           associated with the basis state $\ket{k}$, $\Psi$ is the function to be evaluated for the network, and $x_k$, $y_k$ have the same meaning as above.  `criterion` takes 
 56            two pytorch `Tensors` as inputs, corresponding to the network output and the desired output. This loss function is not an option if `ampl` is True.               
 57
 58    - **arg_dict** : *dict* 
 59
 60        A dictionary containing information on training variables, created with `pqcprep.file_tools.compress_args()` (or created 
 61        with `pqcprep.file_tools.compress_args_ampl()` in the case of `ampl` being True). 
 62
 63    - **ampl** : *boolean* 
 64
 65        If True, the loss function is defined for an amplitude-encoding network, as opposed to a function evaluation network. Default is 
 66        False.     
 67        
 68    Returns: 
 69    ----
 70
 71    - **criterion** : *callable* 
 72
 73        The loss function as a callable object. Number and type of arguments depend on the chosen `loss_str` option (see above).    
 74
 75    """
 76    
 77    if loss_str=="MSE":
 78        from torch.nn import MSELoss
 79        criterion=MSELoss() 
 80    elif loss_str=="L1":
 81        from torch.nn import L1Loss
 82        criterion=L1Loss() 
 83    elif loss_str=="KLD":
 84        from torch.nn import KLDivLoss
 85        criterion=KLDivLoss()
 86    elif loss_str=="CE":
 87        from torch.nn import CrossEntropyLoss
 88        criterion=CrossEntropyLoss()
 89    elif loss_str=="SAM":
 90        def criterion(output, target):
 91            return  torch.abs(1. -torch.sum(torch.mul(output, target))) 
 92    elif loss_str=="WIM":  
 93        if arg_dict["train_superpos"]==False:
 94            raise ValueError(f"The loss function {loss_str} requires training in superposition, i.e. 'train_superpos==True'.") 
 95        if ampl:
 96            raise ValueError(f"The loss function {loss_str} is not available for amplitude training. Allowed options are 'CE', 'MSE', 'L1', 'KLD', 'SAM'.")
 97
 98        def criterion(output, target, weights):
 99            output = torch.mul(output, weights)  
100            output = output / torch.sum(torch.mul(output, output)) 
101            return  torch.abs(1. -torch.sum(torch.mul(output, target)))     
102    elif loss_str=="WILL":  
103        if arg_dict["train_superpos"]==False:
104             raise ValueError(f"The loss function {loss_str} requires training in superposition, i.e. 'train_superpos==True'.")
105        if ampl:
106            raise ValueError(f"The loss function {loss_str} is not available for amplitude training. Allowed options are 'CE', 'MSE', 'L1', 'KLD', 'SAM'.")
107        fx_arr = [psi(i, mode=arg_dict["func_str"]) for i in np.arange(0, 2**arg_dict["n"])]
108        if arg_dict["phase_reduce"]:
109            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
110        
111        fx_arr_rounded = [bin_to_dec(dec_to_bin(i,arg_dict["m"],'unsigned mag', nint=arg_dict["mint"]),'unsigned mag',nint=arg_dict["mint"]) for i in fx_arr]
112        distance_arr = np.empty(2**(arg_dict["n"]+arg_dict["m"]))
113        
114        for i in np.arange(2**arg_dict["n"]):
115            bin_i=dec_to_bin(i,arg_dict["n"],'unsigned mag') 
116            for j in np.arange(2**arg_dict["m"]):
117                bin_j=dec_to_bin(j,arg_dict["m"],'unsigned mag')
118                ind=int(bin_j + bin_i,2) 
119                distance_arr[ind] = np.abs(bin_to_dec(dec_to_bin(j,arg_dict["m"],'unsigned mag'),'unsigned mag',nint=arg_dict["mint"]) - fx_arr_rounded[i])  
120        distance=Tensor(distance_arr)   
121        
122        def criterion(output, target):
123            loss =torch.pow(torch.abs(output-target),arg_dict["WILL_p"]) + torch.mul(torch.abs(output),torch.pow(distance,arg_dict["WILL_q"])) 
124            return torch.sum(loss)**(1/arg_dict["WILL_p"]) / torch.numel(loss)
125    else:
126        raise ValueError("Unrecognised loss function. Options are: 'CE', 'MSE', 'L1', 'KLD', 'SAM', 'WIM', 'WILL'.")
127
128    return criterion 
129
130def set_WIM_weights(generated_weights, arg_dict):
131    """ 
132    
133    Determine the weight coefficients for the WIM loss function. 
134
135    Arguments:
136    ----
137    - **generated_weights** : *array_like*
138
139        The network weights generated in a given epoch. 
140
141    - **arg_dict** : *dict* 
142
143        A dictionary containing information on training variables, created with `pqcprep.file_tools.compress_args()`. 
144
145    Returns:
146    ----
147    - **WIM_weights_arr** : *array_like*
148
149        The calculated coefficients for the WIM loss function. 
150
151    """
152    
153    # initialise arrays to store results 
154    WIM_weights_arr= np.empty(2**(arg_dict["n"]+arg_dict["m"]))
155    
156    # iterate over input states 
157    x_arr_temp=np.arange(2**arg_dict["n"])
158    fx_arr_temp = [psi(k, mode=arg_dict["func_str"]) for k in x_arr_temp]
159
160    if arg_dict["phase_reduce"]: fx_arr_temp = [np.modf(k/ (2* np.pi))[0] for k in fx_arr_temp]
161
162    for q in x_arr_temp:
163        
164        # prepare circuit 
165        enc=binary_to_encode_param(np.binary_repr(q,arg_dict["n"]))
166        params=np.concatenate((enc, generated_weights))  
167
168        qc = generate_network(arg_dict["n"],arg_dict["m"],arg_dict["L"], encode=True,toggle_IL=True, real=arg_dict["real"],repeat_params=arg_dict["repeat_params"])
169        qc = qc.assign_parameters(params) 
170
171        # get target array 
172        target_arr_temp = np.zeros(2**(arg_dict["n"]+arg_dict["m"]))
173
174        index = int(dec_to_bin(fx_arr_temp[q],arg_dict["m"],'unsigned mag',nint=arg_dict["mint"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2)
175        target_arr_temp[index]=1 
176
177        # get statevector 
178        state_vector_temp = get_state_vec(qc)
179
180        # for each output state, calculate the "binary difference" to the target as well as the "coefficient difference"
181        sum = 0 
182        for j in np.arange(2**arg_dict["m"]):
183            ind = int(dec_to_bin(j,arg_dict["m"],'unsigned mag',nint=arg_dict["m"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2)
184            
185            num_dif =np.abs(fx_arr_temp[q] -bin_to_dec(dec_to_bin(j,arg_dict["m"],'unsigned mag', nint=arg_dict["mint"]),'unsigned mag',nint=arg_dict["mint"]))
186            coeff_dif = np.abs(state_vector_temp[ind]- target_arr_temp[ind])
187            sum += num_dif * coeff_dif
188
189        # add to weights_arr 
190        for j in np.arange(2**arg_dict["m"]):
191            ind = int(dec_to_bin(j,arg_dict["m"],'unsigned mag',nint=arg_dict["m"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2) 
192            WIM_weights_arr[ind]= sum    
193
194    # focus on outliers: double-weight on states 0.5 sigma or more above the mean
195    WIM_weights_arr += (WIM_weights_arr >= np.mean(WIM_weights_arr)+0.5 *np.std(WIM_weights_arr) ).astype(int) 
196    
197    # smoothen WIM weights 
198    WIM_weights_arr=np.exp(0.8 * WIM_weights_arr)
199
200    return WIM_weights_arr
201
202def train_QNN(n,m,L, seed, epochs,func_str,loss_str,meta, recover_temp, nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta, DIR):
203    r"""
204    Train a QCNN to perform function evaluation $\ket{j}\ket{0} \mapsto \ket{j}\ket{\Psi(j)}$.
205
206    The QCNN is generated using `pqcprep.pqc_tools.generate_network()`. 
207
208    Arguments:
209    ---
210    - **n** : *int*
211
212        Number of qubits in the input register. 
213
214    - **m** : *int*
215
216        Number of qubits in the target register. 
217
218    - **L** : *int*
219
220        Number of layers in the network. 
221
222    - **seed** : *int* 
223
224        Seed for random number generation. 
225
226    - **epochs** : *int* 
227
228        Number of training runs. 
229
230    - **func_str** : *str*
231
232        String specifying the function $\Psi$ to be evaluated. Must be a valid option for the argument `mode` of `pqcprep.psi_tools.psi()`. 
233
234    - **loss_str** : *str* 
235
236        String specifying the loss function minimised by the optimiser. Must be a valid option for the argument `loss_str` of `set_loss_func()`. 
237
238    - **meta** : *str*
239
240        String containing meta information to be included in output file names. 
241
242    - **recover_temp** : *boolean* 
243
244        If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten. 
245
246    - **nint** : *int*
247
248        Number of integer qubits in input register. 
249
250    - **mint** : *int*
251
252        Number of integer qubits in target register.  
253
254    - **phase_reduce** : *boolean* 
255
256        If True, reduce $\Psi(j)$ to the interval $[0, 2 \pi)$ i.e. perform the mapping $\Psi \to \Psi (\text{mod} \; 2 \pi)$. 
257
258    - **train_superpos** : *boolean*
259
260        If True, train on a superposition of input states. If False, train on randomly sampled individual input states. 
261
262    - **real** : *boolean*
263
264        If True, generate a network only involving CX and Ry rotations, resulting in real amplitudes. 
265
266    - **repeat_params** : *str*, *optional* 
267
268        Keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type. 
269        Options are `None` (do not keep parameters fixed), `'CL'` (keep parameters fixed for convolutional layers), 
270        `'IL'` (keep parameters fixed for input layers), `'both'` (keep parameters fixed for both convolutional and input layers).    
271
272    - **WILL_p** : *float* 
273
274        The $p$ parameter of the WILL loss function, as described in `set_loss_func()`.
275
276    - **WILL_q** : *float* 
277
278        The $q$ parameter of the WILL loss function, as described in `set_loss_func()`.    
279
280    - **delta** : *float* 
281
282        Hyper-parameter controlling the sampling of input state coefficients when training in superposition (`train_superpos = True`). Must be 
283        between 0 and 1. `delta = 0` corresponds to coefficients fixed at $\\frac{1}{\\sqrt{2^n}}$ while `delta = 1` corresponds to coefficients randomly assuming values on the range $(0,1)$. 
284        Intermediate values of `delta` result in coefficinets being randomly sampled on an interval around $\\frac{1}{\\sqrt{2^n}}$, with the range of the interval 
285        determined by the value of `delta`.    
286
287    - **DIR** : *str*
288
289        Parent directory for output files.      
290
291    Returns:
292    ----
293
294    The output produced by the training is saved in binary `.npy` files in the directory `DIR/outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
295    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()` and `<TYPE>` is one of: 
296
297    - `weights` : file containing the QCNN weights determined by the optimiser;
298
299    - `loss` : file containing the loss value after each epoch; 
300
301    - `mismatch` : file containg the mismatch value after each epoch; 
302
303    - `grad` : file containing the  squared weight gradient norm after each epoch;  
304
305    - `vargrad` : file containing the variance of the weight gradients after each epoch.      
306
307    """
308    
309    # compress arguments into dictionary 
310    args =compress_args(n,m,L, seed, epochs,func_str,loss_str,meta,nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta)
311
312    # set precision strings 
313    if nint==None: nint=n
314    if mint==None: mint=m  
315    if phase_reduce: mint=0
316
317    # set seed for PRNG 
318    algorithm_globals.random_seed= seed
319    rng = np.random.default_rng(seed=seed)
320
321    # generate circuit and set up as QNN 
322    qc = generate_network(n,m,L, encode=not train_superpos, toggle_IL=True, initial_IL=True,input_Ry=train_superpos, real=real,repeat_params=repeat_params, wrap=False)
323                                                                                        
324    qnn = SamplerQNN(
325                    circuit=qc.decompose(),           
326                    sampler=Sampler(options={"shots": 10000, "seed": algorithm_globals.random_seed}),
327                    input_params=qc.parameters[:n], 
328                    weight_params=qc.parameters[n:],
329                    input_gradients=True
330                )
331              
332    # choose initial weights
333    recovered_k =0
334    if recover_temp:    
335        recover_labels=["weights", "mismatch", "loss", "grad", "vargrad"]
336        recover_paths={}
337        for k in np.arange(100,epochs, step=100):
338            for e in np.arange(len(recover_labels)):
339                file=os.path.join(DIR,"outputs", f"__TEMP{k}_{recover_labels[e]}{vars_to_name_str(args)}.npy")
340                recover_paths[recover_labels[e]]= (file if os.path.isfile(file) else None)
341
342                if recover_labels[e]=="weights" and os.path.isfile(file):
343                    recovered_k=k+1
344                     
345        if not None in list(recover_paths.values):
346            initial_weights=np.load(recover_paths["weights"])
347        else:
348            initial_weights =np.zeros(len(qc.parameters[n:])) # initialise parameters to zero   
349    else:
350        initial_weights =np.zeros(len(qc.parameters[n:])) # initialise parameters to zero   
351           
352    # initialise TorchConnector
353    model = TorchConnector(qnn, initial_weights)
354
355    # choose optimiser 
356    optimizer = Adam(model.parameters(), lr=0.01, betas=(0.7, 0.999), weight_decay=0.005) 
357                
358    # set up arrays to store training outputs 
359    if recover_temp and not None in list(recover_paths.values):
360        mismatch_vals=np.load(recover_paths["mismatch"])
361        loss_vals=np.load(recover_paths["loss"])
362        grad_vals=np.load(recover_paths["grad"])
363        var_grad_vals=np.load(recover_paths["var_grad"])
364    else:    
365        mismatch_vals = np.empty(epochs)
366        loss_vals = np.empty(epochs)
367        grad_vals = np.empty(epochs)
368        var_grad_vals = np.empty(epochs)
369
370    # generate x and f(x) values
371    pn =n - nint
372    pm =m - mint
373
374    if train_superpos:
375
376        # sample all basis states of input register and convert to binary 
377        x_arr = np.arange(0, 2**n)
378        x_arr_bin =[dec_to_bin(i,n,encoding="unsigned mag") for i in x_arr]
379
380        # apply function and reduce to phase value between 0 and 1 
381        fx_arr = [psi(i, mode=func_str) for i in x_arr]
382
383        if phase_reduce:
384            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
385
386        # convert fx_arr to binary at available target register precision 
387        fx_arr_bin = [dec_to_bin(i,m,nint=mint,encoding="unsigned mag") for i in fx_arr]
388
389        if np.max(fx_arr)> 2.**mint - 2.**(-pm) and mint != 0:
390            raise ValueError(f"Insufficient number of target (integer) qubits.")
391        
392        # get bit strings corresponding to target arrays and convert to indices
393        target_bin = [fx_arr_bin[i]+x_arr_bin[i] for i in x_arr]
394        target_ind = [bin_to_dec(i, encoding='unsigned mag') for i in target_bin]
395
396        # prepare target array 
397        target_arr = np.zeros(2**(n+m))
398        for k in target_ind:
399            target_arr[int(k)]=1
400
401    else:        
402        x_min = 0
403        x_max = 2.**nint - 2.**(-pn) 
404        x_arr = np.array(x_min + (x_max - x_min) *rng.random(size=epochs))
405        fx_arr = [psi(i, mode=func_str) for i in x_arr]
406
407        # reduce to phase value between 0 and 1:
408        if phase_reduce: 
409            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
410        
411        if np.max(fx_arr)> 2.**mint - 2.**(-pm) and mint != 0:
412            raise ValueError(f"Insufficient number of target (integer) qubits.")
413
414    # choose loss function 
415    criterion=set_loss_func(loss_str, args)
416    
417    # start training 
418    print(f"\n\nTraining started. Epochs: {epochs}. Input qubits: {n}. Target qubits: {m}. QCNN layers: {L}. \n")
419    start = time.time() 
420
421    warnings.filterwarnings("ignore", category=UserWarning)
422
423    for i in np.arange(epochs)[recovered_k:]:
424
425        if train_superpos == False:
426            # get input data
427            input = Tensor(binary_to_encode_param(dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint))) 
428
429            # get target data 
430            target_arr = np.zeros(2**(n+m))
431            index = int(dec_to_bin(fx_arr[i],m,'unsigned mag',nint=mint)+dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint),2)
432            target_arr[index]=1 
433            target=Tensor(target_arr)
434        else:
435
436            # generate random coefficients 
437            coeffs = np.array(np.pi / 2 * (1+delta *(2 *rng.random(size=n)-1)))
438            
439            # get input data 
440            input=Tensor(coeffs)
441
442            # get target data 
443            
444            target_ampl = np.empty(2**(n+m))
445
446            for j in np.arange(2**n):
447                for k in np.arange(2**m):
448                    ind =int(dec_to_bin(k,m)+dec_to_bin(j,n),2) 
449                    bin_n = dec_to_bin(j,n)[:: -1] # reverse bit order !
450                    val=1 
451
452                    for l in np.arange(n):
453                        val *= np.cos(coeffs[l]/2) if bin_n[l]=='0' else np.sin(coeffs[l]/2)
454
455                    target_ampl[ind] = val * target_arr[ind] 
456
457            target=Tensor(target_ampl**2)
458
459        # train model  
460        optimizer.zero_grad()
461
462        # apply loss function          
463        if loss_str=="WIM":
464            WIM_weights_arr=np.ones(2**(n+m)) if i==recovered_k else WIM_weights_arr
465            WIM_weights_tensor=Tensor(WIM_weights_arr)
466            loss =criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target), WIM_weights_tensor)    # add small number in sqrt to avoid zero grad !
467        else: 
468            loss = criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target))                       # add small number in sqrt to avoid zero grad !
469
470        # propagate gradients and recompute weights
471        loss.backward()
472        optimizer.step()
473
474        # save loss and grad for plotting 
475        loss_vals[i]=loss.item()
476        grad_vals[i]=np.sum(model.weight.grad.numpy()**2)
477        var_grad_vals[i]=np.std(model.weight.grad.numpy())**2
478        
479        # set up circuit with calculated weights
480        circ = generate_network(n,m,L, encode=not train_superpos, toggle_IL=True, initial_IL=True,input_Ry=train_superpos, real=real,repeat_params=repeat_params, wrap=False)
481        
482        with no_grad():
483            generated_weights = model.weight.detach().numpy()   
484        if train_superpos:
485            input_params = coeffs
486            params=np.concatenate((input_params, generated_weights))  
487        else:
488            input_params = binary_to_encode_param(dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint))
489            params = np.concatenate((input_params, generated_weights))   
490        circ = circ.assign_parameters(params)
491
492        # get statevector 
493        state_vector = get_state_vec(circ)
494
495        # calculate fidelity and mismatch
496        target_state = target_ampl**2 if train_superpos else target_arr 
497        fidelity = np.abs(np.dot(np.sqrt(target_state),np.conjugate(state_vector)))**2
498        mismatch = 1. - np.sqrt(fidelity)
499        mismatch_vals[i]=mismatch
500
501        # set loss func weights for WIM
502        if loss_str=="WIM" and (i % 10 ==0) and (i >=1): WIM_weights_arr= set_WIM_weights(generated_weights, args)
503            
504        # temporarily save outputs every hundred iterations
505        temp_ind = epochs - 100 
506        
507        if recover_temp:
508            temp_ind = recovered_k -1
509
510        if (i % 100 ==0) and (i != 0) and (i != epochs-1): 
511
512            temp_labels=["weights", "mismatch", "loss", "grad", "vargrad"] 
513            temp_arrs=[generated_weights, mismatch_vals, loss_vals, grad_vals, var_grad_vals] 
514
515            for e in np.arange(len(temp_labels)):
516                # save temp file 
517                file=os.path.join(DIR,"outputs",f"__TEMP{i}_{temp_labels[e]}{vars_to_name_str(args)}")
518                np.save(file,temp_arrs[e])
519
520                # delete previous temp file 
521                old_file=os.path.join(DIR,"outputs",f"__TEMP{i-100}_{temp_labels[e]}{vars_to_name_str(args)}.npy")
522                os.remove(old_file) if os.path.isfile(old_file) else None
523
524            # make note of last created temp files
525            temp_ind = i   
526        
527        # print status
528        a = int(20*(i+1)/epochs)
529       
530        if i==recovered_k:
531            time_str="--:--:--.--"
532        elif i==epochs-1:
533            time_str="00:00:00.00"    
534        else:
535            if recover_temp:
536                    remaining = ((time.time() - start) / (i-recovered_k)) * (epochs - i)
537            else:
538                remaining = ((time.time() - start) / i) * (epochs - i)
539            mins, sec = divmod(remaining, 60)
540            hours, mins = divmod(mins, 60)
541            time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}"
542
543        prefix="\t" 
544        print(f"{prefix}[{u'█'*a}{('.'*(20-a))}] {100.*((i+1)/epochs):.2f}% ; Loss {loss_vals[i]:.2e} ; Mismatch {mismatch:.2e} ; ETA {time_str}", end='\r', file=sys.stdout, flush=True)
545        
546        
547    print(" ", flush=True, file=sys.stdout)
548    
549    warnings.filterwarnings("default", category=UserWarning)
550
551    elapsed = time.time()-start
552    mins, sec = divmod(elapsed, 60)
553    hours, mins = divmod(mins, 60)
554    time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}" 
555
556    # decompose circuit for gate count 
557    num_CX = dict(circ.decompose(reps=4).count_ops())["cx"]
558    num_gates = num_CX + dict(circ.decompose(reps=4).count_ops())["u"]
559    print(f"\nTraining completed in {time_str}. Number of weights: {len(generated_weights)}. Number of gates: {num_gates} (of which CX gates: {num_CX}). \n\n")
560
561    # delete temp files
562    temp_labels=["weights", "mismatch", "loss", "grad", "vargrad"]  
563    for i in np.arange(len(temp_labels)):
564        file=os.path.join(DIR,"outputs",f"__TEMP{temp_ind}_{temp_labels[i]}{vars_to_name_str(args)}.npy")
565        os.remove(file) if os.path.isfile(file) else None
566                            
567    # save outputs 
568    with no_grad():
569            generated_weights = model.weight.detach().numpy()
570    outputs= [generated_weights, mismatch_vals, loss_vals, grad_vals, var_grad_vals]
571    output_labels=["weights", "mismatch", "loss", "grad", "vargrad"]  
572    for i in np.arange(len(outputs)):
573        np.save(os.path.join(DIR,"outputs", f"{output_labels[i]}{vars_to_name_str(args)}"), outputs[i])      
574
575    return 0 
576
577def test_QNN(n,m,L,seed,epochs, func_str,loss_str,meta,nint,mint,phase_reduce,train_superpos,real,repeat_params,WILL_p, WILL_q,delta,DIR,verbose=True):   
578    """
579    Test performance of a QCNN trained for function evaluation with respect to different metrics. 
580
581    This requires the existence of an appropriate `weights_<NAME_STR>.npy` file (as produced by `train_QNN()`) in the directory `DIR/outputs`, where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()`. 
582
583    Arguments: 
584    ---
585
586    Same arguments as `train_QNN()`. See there for a description. 
587
588    Returns:
589    ---
590
591    The testing output produced is saved in binary `.npy` files in the directory `DIR/ampl_outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
592    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()` and `<TYPE>` is one of: 
593 
594    - `mismatch_by_state` : file containing the mismatch between the state produced by the network and the desired output state for each of the possible input 
595        register states. Contains a dictionary with the input bit strings as keys and the associated mismatch as values. 
596
597    - `phase` : file containing the phase function encoded by the network when the input register is in an equal superposition of input states. 
598
599    - `metrics` : file containing a dictionary with several metrics evaluating the performance of the network. These metrics are 
600        * `mu` : mean mismatch (mean of the data contained in `mismatch_by_state_<NAME_STR>.npy`); should be zero for ideal performance
601        * `sigma` : mismatch standard deviation (standard deviation of the data contained in `mismatch_by_state_<NAME_STR>.npy`);  should be zero for ideal performance
602        * `eps` : normalisation error on the state vector associated with the data contained in `phase_<NAME_STR>.npy`;  should be zero for ideal performance
603        * `chi` : mean absolute difference between the phase function contained in `phase_<NAME_STR>.npy` and the rounded desired phase function;  should be zero for ideal performance
604        * `omega` : a super-metric defined as `1/(mu + sigma + eps + chi)` ; should be maximal for ideal performance
605
606    """
607    # compress arguments into dictionary 
608    args =compress_args(n,m,L, seed, epochs,func_str,loss_str,meta,nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta)
609    name_str=vars_to_name_str(args)  
610
611    # set precision strings 
612    if nint==None: nint=n
613    if mint==None: mint=m  
614    if phase_reduce: mint=0                  
615
616    # load weights 
617    if os.path.isfile(os.path.join(DIR,"outputs",f"weights{name_str}.npy")):
618        weights = np.load(os.path.join(DIR,"outputs",f"weights{name_str}.npy"))
619    else:
620        raise ValueError("No appropriate QCNN weights could be found. Check the network configuration as well as the relevant directory.")    
621
622    # initialise array to store results 
623    mismatch = np.empty(2**n)
624    signs = np.empty(2**n)
625    
626    # iterate over input states 
627    x_arr = np.arange(2**n)
628    fx_arr = [psi(i, mode=func_str) for i in x_arr]
629
630    if phase_reduce: 
631        fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
632
633    for i in x_arr:
634        
635        # prepare circuit 
636        enc=binary_to_encode_param(np.binary_repr(i,n))
637        params=np.concatenate((enc, weights))  
638
639        circ = generate_network(n,m,L, encode=True,toggle_IL=True, real=real,repeat_params=repeat_params)
640        circ = circ.assign_parameters(params) 
641
642        # get target array 
643        target_arr = np.zeros(2**(n+m))
644
645        index = int(dec_to_bin(fx_arr[i],m,'unsigned mag',nint=mint)+dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint),2)
646        target_arr[index]=1 
647
648        # get statevector 
649        state_vector = get_state_vec(circ)
650
651        signs[i]=np.sign(np.sum(np.real(state_vector)*np.sqrt(target_arr)))
652
653        # calculate fidelity and mismatch 
654        fidelity = np.abs(np.dot(np.sqrt(target_arr),np.conjugate(state_vector)))**2
655        mismatch[i] = 1. - np.sqrt(fidelity) 
656            
657    # get phase target 
658    fx_arr_bin = [dec_to_bin(i,m, "unsigned mag", 0) for i in fx_arr]
659    phase_target =  np.array([bin_to_dec(i,"unsigned mag", mint) for i in fx_arr_bin])
660    if phase_reduce:
661        phase_target *= 2 * np.pi 
662
663    # get state vector for full phase extraction 
664    state_vec= full_encode(n,m, weights_A_str=None, weights_p_str=weights, L_A=None, L_p=L,real_p=real,repeat_params=repeat_params,full_state_vec=False, no_UA=True, operators="QRQ")
665    phase = phase_from_state(state_vec)
666
667    # calculate metrics
668    mu = np.mean(mismatch)
669    sigma = np.std(mismatch) 
670    eps = 1 - np.sum(np.abs(state_vec)**2)
671    chi = np.mean(np.abs(phase - phase_target))
672    omega= 1/(mu+sigma+eps+chi) 
673
674    # save outputs
675    full_dic = dict(zip(x_arr, mismatch)) 
676    np.save(os.path.join(DIR,"outputs",f"mismatch_by_state{name_str}.npy"), full_dic) 
677
678    metric_dic = {} 
679    metric_dic["mu"]=mu 
680    metric_dic["sigma"]=sigma   
681    metric_dic["eps"]=eps 
682    metric_dic["chi"]=chi
683    metric_dic["omega"]=omega  
684    np.save(os.path.join(DIR,"outputs",f"metrics{name_str}.npy"), metric_dic) 
685
686    np.save(os.path.join(DIR,"outputs",f"phase{name_str}.npy"), phase) 
687
688    if verbose:
689        print("Mismatch by input state:")
690        for i in x_arr:
691            print(f"\t{np.binary_repr(i,n)}:  {mismatch[i]:.2e} ({signs[i]})")
692        print("-----------------------------------")
693        print(f"Mu: \t{mu:.3e}") 
694        print(f"Sigma: \t{sigma:.3e}") 
695        print(f"Eps: \t{eps:.3e}")
696        print(f"Chi: \t{chi:.3e}") 
697        print(f"Omega: \t{omega:.3f}")
698        print("-----------------------------------")
699        print("")
700        print("")    
701
702    return 0 
703
704def ampl_train_QNN(n,L,x_min,x_max,seed, epochs,func_str,loss_str,meta, recover_temp, nint, repeat_params, DIR):
705    """
706
707    Train a QCNN to prepare an amplitude distribution: $\ket{0} \mapsto \sum_j A(j) \ket{j}$.
708
709    The QCNN is generated using `pqcprep.pqc_tools.A_generate_network()`. 
710
711    Arguments:
712    ---
713
714    - **n** : *int* 
715
716        Number of qubits in the register. 
717
718    - **L** : *int* 
719
720        Number of layers in the network. 
721
722    - **x_min** : *float* 
723
724        Minimum of function domain.         
725
726    - **x_max** : *float* 
727
728        Maximum of function domain.   
729
730    - **seed** : *int* 
731
732        Seed for random number generation. 
733
734    - **epochs** : *int* 
735
736        Number of training runs. 
737
738    - **func_str** : *str*
739
740        String specifying the function $A$ to be prepared. Must be a valid option for the argument `mode` of `pqcprep.psi_tools.A()`. 
741
742    - **loss_str** : *str* 
743
744        String specifying the loss function minimised by the optimiser. Must be a valid option for the argument `loss_str` of `set_loss_func()`. 
745
746    - **meta** : *str*
747
748        String containing meta information to be included in output file names. 
749
750    - **recover_temp** : *boolean* 
751
752        If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten. 
753
754    - **nint** : *int*
755
756        Number of integer qubits in the register.  
757
758    - **repeat_params** : *boolean* 
759
760        If True, keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type.
761
762    - **DIR** : *str*
763
764        Parent directory for output files.     
765          
766
767    Returns:
768    ---
769
770    The output produced by the training is saved in binary `.npy` files in the directory `DIR/ampl_outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
771    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str_ampl()` and `<TYPE>` is one of: 
772
773    - `weights` : file containing the QCNN weights determined by the optimiser;
774
775    - `state_vec` : file containing the statevector corresponding to the register after applying the QCNN; 
776
777    - `loss` : file containing the loss value after each training run; 
778
779    - `mismatch` : file containg the mismatch value after each training run.   
780 
781        
782    """
783
784    # compress arguments into dictionary 
785    args=compress_args_ampl(n,L,x_min,x_max,seed, epochs,func_str,loss_str,meta, nint, repeat_params)
786
787    # set seed for PRNG 
788    algorithm_globals.random_seed= seed
789    rng = np.random.default_rng(seed=seed)
790    
791    # generate circuit and set up as QNN 
792    qc = A_generate_network(n,L,repeat_params)
793    qnn = SamplerQNN(
794            circuit=qc.decompose(),            # decompose to avoid data copying (?)
795            sampler=Sampler(options={"shots": 10000, "seed": algorithm_globals.random_seed}),
796            weight_params=qc.parameters, 
797            input_params=[],  
798            input_gradients=False 
799        )
800    
801    # set precision 
802    if nint==None: nint=n   
803    
804    # choose initial weights
805    recovered_k =0
806
807    if recover_temp:    
808        recover_labels=["weights", "mismatch", "loss"]
809        recover_paths={}
810
811        for k in np.arange(100,epochs, step=100):
812            for e in np.arange(len(recover_labels)):
813                file=os.path.join(DIR,"ampl_outputs", f"__TEMP{k}_{recover_labels[e]}{vars_to_name_str_ampl(args)}.npy")
814                recover_paths[recover_labels[e]]= (file if os.path.isfile(file) else None)
815
816                if recover_labels[e]=="weights" and os.path.isfile(file):
817                    recovered_k=k+1      
818        
819        if not None in list(recover_paths.values):
820            initial_weights=np.load(recover_paths["weights"])
821        else:
822            initial_weights =np.zeros(len(qc.parameters)) # initialise parameters to zero   
823    
824    else:
825        initial_weights = np.zeros(len(qc.parameters)) # initialise parameters to zero   
826    
827    # initialise TorchConnector
828    model = TorchConnector(qnn, initial_weights)
829
830    # choose optimiser and loss function 
831    optimizer = Adam(model.parameters(), lr=0.01, betas=(0.7, 0.999), weight_decay=0.005) # Adam optimizer 
832    criterion=set_loss_func(loss_str, args, ampl=True)
833                    
834    # set up arrays to store training outputs 
835    if recover_temp and not None in list(recover_paths.values):
836        mismatch_vals=np.load(recover_paths["mismatch"])
837        loss_vals=np.load(recover_paths["loss"])
838    else:    
839        mismatch_vals = np.empty(epochs)
840        loss_vals = np.empty(epochs)
841
842    # calculate target and normalise 
843    dx = (x_max-x_min)/(2**n)
844    target_arr = np.array([A(i, mode=func_str) for i in np.arange(x_min,x_max, dx)])**2
845    target_arr = target_arr / np.sum(target_arr)
846    
847    # start training 
848    print(f"\n\nTraining started. Epochs: {epochs}. Input qubits: {n}. Function range: [{x_min},{x_max}]. QCNN layers: {L}. \n")
849    start = time.time() 
850
851    warnings.filterwarnings("ignore", category=UserWarning)
852
853    for i in np.arange(epochs)[recovered_k:]:
854
855        # get input data
856        input = Tensor([]) 
857
858        # get target data 
859        target=Tensor(target_arr)
860
861        # train model  
862        optimizer.zero_grad()
863        loss = criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target)) # adding 1e-10 to prevent taking sqrt(0) ??!!
864        loss.backward()
865        optimizer.step()
866
867        # save loss for plotting 
868        loss_vals[i]=loss.item()
869
870        # set up circuit with calculated weights
871        circ = A_generate_network(n,L, repeat_params)
872
873        with no_grad():
874            generated_weights = model.weight.detach().numpy()
875        
876        circ = circ.assign_parameters(generated_weights)    
877
878        # get statevector 
879        state_vector = get_state_vec(circ)
880        
881        # calculate fidelity and mismatch 
882        fidelity = np.abs(np.dot(np.sqrt(target_arr),np.conjugate(state_vector)))**2
883        mismatch = 1. - np.sqrt(fidelity)
884
885        # save mismatch for plotting 
886        mismatch_vals[i]=mismatch
887
888        # temporarily save outputs every hundred iterations
889        temp_ind = epochs - 100 
890        
891        if recover_temp:
892            temp_ind = recovered_k -1
893
894        if (i % 100 ==0) and (i != 0) and (i != epochs-1): 
895
896            temp_labels=["weights", "mismatch", "loss"] 
897            temp_arrs=[generated_weights, mismatch_vals, loss_vals] 
898
899            for e in np.arange(len(temp_labels)):
900                # save temp file 
901                file=os.path.join(DIR,"ampl_outputs",f"__TEMP{i}_{temp_labels[e]}{vars_to_name_str_ampl(args)}")
902                np.save(file,temp_arrs[e])
903
904                # delete previous temp file 
905                old_file=os.path.join(DIR,"ampl_outputs",f"__TEMP{i-100}_{temp_labels[e]}{vars_to_name_str_ampl(args)}.npy")
906                os.remove(old_file) if os.path.isfile(old_file) else None
907
908            # make note of last created temp files
909            temp_ind = i 
910         
911        # print status
912        a = int(20*(i+1)/epochs)
913
914        if i==recovered_k:
915            time_str="--:--:--.--"
916        elif i==epochs-1:
917            time_str="00:00:00.00"    
918        else:
919            if recover_temp:
920                remaining = ((time.time() - start) / (i-recovered_k)) * (epochs - i)
921            else:
922                remaining = ((time.time() - start) / i) * (epochs - i)
923            mins, sec = divmod(remaining, 60)
924            hours, mins = divmod(mins, 60)
925            time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}"
926
927        prefix="\t" 
928        print(f"{prefix}[{u'█'*a}{('.'*(20-a))}] {100.*((i+1)/epochs):.2f}% ; Loss {loss_vals[i]:.2e} ; Mismatch {mismatch:.2e} ; ETA {time_str}", end='\r', file=sys.stdout, flush=True)
929        
930    warnings.filterwarnings("default", category=UserWarning)
931
932    print(" ", flush=True, file=sys.stdout)
933
934    elapsed = time.time()-start
935    mins, sec = divmod(elapsed, 60)
936    hours, mins = divmod(mins, 60)
937    time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}" 
938
939    # decompose circuit for gate count 
940    num_CX = dict(circ.decompose(reps=4).count_ops())["cx"]
941    num_gates = num_CX + dict(circ.decompose(reps=4).count_ops())["u"]
942
943    print(f"\nTraining completed in {time_str}. Number of weights: {len(generated_weights)}. Number of gates: {num_gates} (of which CX gates: {num_CX}). \n\n")
944
945    # delete temp files
946    temp_labels=["weights", "mismatch", "loss"]  
947    for i in np.arange(len(temp_labels)):
948        file=os.path.join(DIR,"ampl_outputs",f"__TEMP{temp_ind}_{temp_labels[i]}{vars_to_name_str_ampl(args)}.npy")
949        os.remove(file) if os.path.isfile(file) else None             
950
951    # save outputs 
952    with no_grad():
953            generated_weights = model.weight.detach().numpy()
954    outputs= [generated_weights, mismatch_vals, loss_vals, state_vector]
955    output_labels=["weights", "mismatch", "loss", "statevec"]  
956    for i in np.arange(len(outputs)):
957        np.save(os.path.join(DIR,"ampl_outputs", f"{output_labels[i]}{vars_to_name_str_ampl(args)}"), outputs[i])         
958
959    return 0
def set_loss_func(loss_str, arg_dict, ampl=False):
 23def set_loss_func(loss_str, arg_dict, ampl=False):
 24    r"""
 25    Set the loss function to be used in training a network. 
 26
 27    Arguments:
 28    ----
 29
 30    - **loss_str** : *str*
 31
 32        String specifying the loss function to use. Options are 
 33
 34        - `'MSE'` : mean squared error loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.MSELoss.html) with default settings. 
 35          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output. 
 36
 37        - `'L1'` : mean absolute error loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.L1Loss.html) with default settings. 
 38          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.    
 39
 40        - `'KLD'` : Kullback-Leibler divergence loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.KLDivLoss.html) with default settings. 
 41          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.   
 42
 43        - `'CE'` : cross entropy loss. Using [pytorch's implementation](https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html) with default settings. 
 44          `criterion` takes two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.  
 45
 46        - `'SAM'` : sign-adjusted mismatch. Defined as $$\text{SAM}(\ket{x}, \ket{y}) =  1 - \sum_k |x_k| |y_k|,$$ where $\ket{x}$, $\ket{y}$ are the 
 47            network output and desired output, respectively, and $x_k$, $y_k$ are the coefficients w.r.t the two-register computational basis states.  `criterion` takes 
 48            two pytorch `Tensors` as inputs, corresponding to the network output and the desired output.       
 49
 50        - `'WIM'` : weighted mismatch. Defined analogously to SAM but with additional weights: $$\text{WIM}(\ket{x}, \ket{y}) =1 - \sum_k w_k |x_k||y_k|.$$
 51           `criterion` takes three pytorch `Tensors` as inputs, corresponding to the network output, the desired output and the weights. See `set_WIM_weights()`
 52           for information on how the weights are calculated. This loss function is not an option if `ampl` is True. 
 53
 54        - `'WILL'` : weighted Lp loss. Defined as $$\text{WILL}(\ket{x}, \ket{y}; p,q) = \sum_k |x_k -y_k|^p + |x_k| |[k]_m - \Psi([k]_n)|^q,$$ where $p$ and $q$ are coefficients stored in 
 55           `arg_dict['WILL_p']`, `arg_dict['WILL_q']`, $[k]_n$ is the target register bit-string associated with the basis state $\ket{k}$, $[k]_n$ is the input register bit-string 
 56           associated with the basis state $\ket{k}$, $\Psi$ is the function to be evaluated for the network, and $x_k$, $y_k$ have the same meaning as above.  `criterion` takes 
 57            two pytorch `Tensors` as inputs, corresponding to the network output and the desired output. This loss function is not an option if `ampl` is True.               
 58
 59    - **arg_dict** : *dict* 
 60
 61        A dictionary containing information on training variables, created with `pqcprep.file_tools.compress_args()` (or created 
 62        with `pqcprep.file_tools.compress_args_ampl()` in the case of `ampl` being True). 
 63
 64    - **ampl** : *boolean* 
 65
 66        If True, the loss function is defined for an amplitude-encoding network, as opposed to a function evaluation network. Default is 
 67        False.     
 68        
 69    Returns: 
 70    ----
 71
 72    - **criterion** : *callable* 
 73
 74        The loss function as a callable object. Number and type of arguments depend on the chosen `loss_str` option (see above).    
 75
 76    """
 77    
 78    if loss_str=="MSE":
 79        from torch.nn import MSELoss
 80        criterion=MSELoss() 
 81    elif loss_str=="L1":
 82        from torch.nn import L1Loss
 83        criterion=L1Loss() 
 84    elif loss_str=="KLD":
 85        from torch.nn import KLDivLoss
 86        criterion=KLDivLoss()
 87    elif loss_str=="CE":
 88        from torch.nn import CrossEntropyLoss
 89        criterion=CrossEntropyLoss()
 90    elif loss_str=="SAM":
 91        def criterion(output, target):
 92            return  torch.abs(1. -torch.sum(torch.mul(output, target))) 
 93    elif loss_str=="WIM":  
 94        if arg_dict["train_superpos"]==False:
 95            raise ValueError(f"The loss function {loss_str} requires training in superposition, i.e. 'train_superpos==True'.") 
 96        if ampl:
 97            raise ValueError(f"The loss function {loss_str} is not available for amplitude training. Allowed options are 'CE', 'MSE', 'L1', 'KLD', 'SAM'.")
 98
 99        def criterion(output, target, weights):
100            output = torch.mul(output, weights)  
101            output = output / torch.sum(torch.mul(output, output)) 
102            return  torch.abs(1. -torch.sum(torch.mul(output, target)))     
103    elif loss_str=="WILL":  
104        if arg_dict["train_superpos"]==False:
105             raise ValueError(f"The loss function {loss_str} requires training in superposition, i.e. 'train_superpos==True'.")
106        if ampl:
107            raise ValueError(f"The loss function {loss_str} is not available for amplitude training. Allowed options are 'CE', 'MSE', 'L1', 'KLD', 'SAM'.")
108        fx_arr = [psi(i, mode=arg_dict["func_str"]) for i in np.arange(0, 2**arg_dict["n"])]
109        if arg_dict["phase_reduce"]:
110            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
111        
112        fx_arr_rounded = [bin_to_dec(dec_to_bin(i,arg_dict["m"],'unsigned mag', nint=arg_dict["mint"]),'unsigned mag',nint=arg_dict["mint"]) for i in fx_arr]
113        distance_arr = np.empty(2**(arg_dict["n"]+arg_dict["m"]))
114        
115        for i in np.arange(2**arg_dict["n"]):
116            bin_i=dec_to_bin(i,arg_dict["n"],'unsigned mag') 
117            for j in np.arange(2**arg_dict["m"]):
118                bin_j=dec_to_bin(j,arg_dict["m"],'unsigned mag')
119                ind=int(bin_j + bin_i,2) 
120                distance_arr[ind] = np.abs(bin_to_dec(dec_to_bin(j,arg_dict["m"],'unsigned mag'),'unsigned mag',nint=arg_dict["mint"]) - fx_arr_rounded[i])  
121        distance=Tensor(distance_arr)   
122        
123        def criterion(output, target):
124            loss =torch.pow(torch.abs(output-target),arg_dict["WILL_p"]) + torch.mul(torch.abs(output),torch.pow(distance,arg_dict["WILL_q"])) 
125            return torch.sum(loss)**(1/arg_dict["WILL_p"]) / torch.numel(loss)
126    else:
127        raise ValueError("Unrecognised loss function. Options are: 'CE', 'MSE', 'L1', 'KLD', 'SAM', 'WIM', 'WILL'.")
128
129    return criterion 

Set the loss function to be used in training a network.

Arguments:

  • loss_str : str

    String specifying the loss function to use. Options are

    • 'MSE' : mean squared error loss. Using pytorch's implementation with default settings. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output.

    • 'L1' : mean absolute error loss. Using pytorch's implementation with default settings. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output.

    • 'KLD' : Kullback-Leibler divergence loss. Using pytorch's implementation with default settings. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output.

    • 'CE' : cross entropy loss. Using pytorch's implementation with default settings. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output.

    • 'SAM' : sign-adjusted mismatch. Defined as $$\text{SAM}(\ket{x}, \ket{y}) = 1 - \sum_k |x_k| |y_k|,$$ where $\ket{x}$, $\ket{y}$ are the network output and desired output, respectively, and $x_k$, $y_k$ are the coefficients w.r.t the two-register computational basis states. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output.

    • 'WIM' : weighted mismatch. Defined analogously to SAM but with additional weights: $$\text{WIM}(\ket{x}, \ket{y}) =1 - \sum_k w_k |x_k||y_k|.$$ criterion takes three pytorch Tensors as inputs, corresponding to the network output, the desired output and the weights. See set_WIM_weights() for information on how the weights are calculated. This loss function is not an option if ampl is True.

    • 'WILL' : weighted Lp loss. Defined as $$\text{WILL}(\ket{x}, \ket{y}; p,q) = \sum_k |x_k -y_k|^p + |x_k| |[k]_m - \Psi([k]_n)|^q,$$ where $p$ and $q$ are coefficients stored in arg_dict['WILL_p'], arg_dict['WILL_q'], $[k]_n$ is the target register bit-string associated with the basis state $\ket{k}$, $[k]_n$ is the input register bit-string associated with the basis state $\ket{k}$, $\Psi$ is the function to be evaluated for the network, and $x_k$, $y_k$ have the same meaning as above. criterion takes two pytorch Tensors as inputs, corresponding to the network output and the desired output. This loss function is not an option if ampl is True.

  • arg_dict : dict

    A dictionary containing information on training variables, created with pqcprep.file_tools.compress_args() (or created with pqcprep.file_tools.compress_args_ampl() in the case of ampl being True).

  • ampl : boolean

    If True, the loss function is defined for an amplitude-encoding network, as opposed to a function evaluation network. Default is False.

Returns:

  • criterion : callable

    The loss function as a callable object. Number and type of arguments depend on the chosen loss_str option (see above).

def set_WIM_weights(generated_weights, arg_dict):
131def set_WIM_weights(generated_weights, arg_dict):
132    """ 
133    
134    Determine the weight coefficients for the WIM loss function. 
135
136    Arguments:
137    ----
138    - **generated_weights** : *array_like*
139
140        The network weights generated in a given epoch. 
141
142    - **arg_dict** : *dict* 
143
144        A dictionary containing information on training variables, created with `pqcprep.file_tools.compress_args()`. 
145
146    Returns:
147    ----
148    - **WIM_weights_arr** : *array_like*
149
150        The calculated coefficients for the WIM loss function. 
151
152    """
153    
154    # initialise arrays to store results 
155    WIM_weights_arr= np.empty(2**(arg_dict["n"]+arg_dict["m"]))
156    
157    # iterate over input states 
158    x_arr_temp=np.arange(2**arg_dict["n"])
159    fx_arr_temp = [psi(k, mode=arg_dict["func_str"]) for k in x_arr_temp]
160
161    if arg_dict["phase_reduce"]: fx_arr_temp = [np.modf(k/ (2* np.pi))[0] for k in fx_arr_temp]
162
163    for q in x_arr_temp:
164        
165        # prepare circuit 
166        enc=binary_to_encode_param(np.binary_repr(q,arg_dict["n"]))
167        params=np.concatenate((enc, generated_weights))  
168
169        qc = generate_network(arg_dict["n"],arg_dict["m"],arg_dict["L"], encode=True,toggle_IL=True, real=arg_dict["real"],repeat_params=arg_dict["repeat_params"])
170        qc = qc.assign_parameters(params) 
171
172        # get target array 
173        target_arr_temp = np.zeros(2**(arg_dict["n"]+arg_dict["m"]))
174
175        index = int(dec_to_bin(fx_arr_temp[q],arg_dict["m"],'unsigned mag',nint=arg_dict["mint"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2)
176        target_arr_temp[index]=1 
177
178        # get statevector 
179        state_vector_temp = get_state_vec(qc)
180
181        # for each output state, calculate the "binary difference" to the target as well as the "coefficient difference"
182        sum = 0 
183        for j in np.arange(2**arg_dict["m"]):
184            ind = int(dec_to_bin(j,arg_dict["m"],'unsigned mag',nint=arg_dict["m"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2)
185            
186            num_dif =np.abs(fx_arr_temp[q] -bin_to_dec(dec_to_bin(j,arg_dict["m"],'unsigned mag', nint=arg_dict["mint"]),'unsigned mag',nint=arg_dict["mint"]))
187            coeff_dif = np.abs(state_vector_temp[ind]- target_arr_temp[ind])
188            sum += num_dif * coeff_dif
189
190        # add to weights_arr 
191        for j in np.arange(2**arg_dict["m"]):
192            ind = int(dec_to_bin(j,arg_dict["m"],'unsigned mag',nint=arg_dict["m"])+dec_to_bin(x_arr_temp[q],arg_dict["n"],'unsigned mag',nint=arg_dict["nint"]),2) 
193            WIM_weights_arr[ind]= sum    
194
195    # focus on outliers: double-weight on states 0.5 sigma or more above the mean
196    WIM_weights_arr += (WIM_weights_arr >= np.mean(WIM_weights_arr)+0.5 *np.std(WIM_weights_arr) ).astype(int) 
197    
198    # smoothen WIM weights 
199    WIM_weights_arr=np.exp(0.8 * WIM_weights_arr)
200
201    return WIM_weights_arr

Determine the weight coefficients for the WIM loss function.

Arguments:

  • generated_weights : array_like

    The network weights generated in a given epoch.

  • arg_dict : dict

    A dictionary containing information on training variables, created with pqcprep.file_tools.compress_args().

Returns:

  • WIM_weights_arr : array_like

    The calculated coefficients for the WIM loss function.

def train_QNN( n, m, L, seed, epochs, func_str, loss_str, meta, recover_temp, nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta, DIR):
203def train_QNN(n,m,L, seed, epochs,func_str,loss_str,meta, recover_temp, nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta, DIR):
204    r"""
205    Train a QCNN to perform function evaluation $\ket{j}\ket{0} \mapsto \ket{j}\ket{\Psi(j)}$.
206
207    The QCNN is generated using `pqcprep.pqc_tools.generate_network()`. 
208
209    Arguments:
210    ---
211    - **n** : *int*
212
213        Number of qubits in the input register. 
214
215    - **m** : *int*
216
217        Number of qubits in the target register. 
218
219    - **L** : *int*
220
221        Number of layers in the network. 
222
223    - **seed** : *int* 
224
225        Seed for random number generation. 
226
227    - **epochs** : *int* 
228
229        Number of training runs. 
230
231    - **func_str** : *str*
232
233        String specifying the function $\Psi$ to be evaluated. Must be a valid option for the argument `mode` of `pqcprep.psi_tools.psi()`. 
234
235    - **loss_str** : *str* 
236
237        String specifying the loss function minimised by the optimiser. Must be a valid option for the argument `loss_str` of `set_loss_func()`. 
238
239    - **meta** : *str*
240
241        String containing meta information to be included in output file names. 
242
243    - **recover_temp** : *boolean* 
244
245        If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten. 
246
247    - **nint** : *int*
248
249        Number of integer qubits in input register. 
250
251    - **mint** : *int*
252
253        Number of integer qubits in target register.  
254
255    - **phase_reduce** : *boolean* 
256
257        If True, reduce $\Psi(j)$ to the interval $[0, 2 \pi)$ i.e. perform the mapping $\Psi \to \Psi (\text{mod} \; 2 \pi)$. 
258
259    - **train_superpos** : *boolean*
260
261        If True, train on a superposition of input states. If False, train on randomly sampled individual input states. 
262
263    - **real** : *boolean*
264
265        If True, generate a network only involving CX and Ry rotations, resulting in real amplitudes. 
266
267    - **repeat_params** : *str*, *optional* 
268
269        Keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type. 
270        Options are `None` (do not keep parameters fixed), `'CL'` (keep parameters fixed for convolutional layers), 
271        `'IL'` (keep parameters fixed for input layers), `'both'` (keep parameters fixed for both convolutional and input layers).    
272
273    - **WILL_p** : *float* 
274
275        The $p$ parameter of the WILL loss function, as described in `set_loss_func()`.
276
277    - **WILL_q** : *float* 
278
279        The $q$ parameter of the WILL loss function, as described in `set_loss_func()`.    
280
281    - **delta** : *float* 
282
283        Hyper-parameter controlling the sampling of input state coefficients when training in superposition (`train_superpos = True`). Must be 
284        between 0 and 1. `delta = 0` corresponds to coefficients fixed at $\\frac{1}{\\sqrt{2^n}}$ while `delta = 1` corresponds to coefficients randomly assuming values on the range $(0,1)$. 
285        Intermediate values of `delta` result in coefficinets being randomly sampled on an interval around $\\frac{1}{\\sqrt{2^n}}$, with the range of the interval 
286        determined by the value of `delta`.    
287
288    - **DIR** : *str*
289
290        Parent directory for output files.      
291
292    Returns:
293    ----
294
295    The output produced by the training is saved in binary `.npy` files in the directory `DIR/outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
296    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()` and `<TYPE>` is one of: 
297
298    - `weights` : file containing the QCNN weights determined by the optimiser;
299
300    - `loss` : file containing the loss value after each epoch; 
301
302    - `mismatch` : file containg the mismatch value after each epoch; 
303
304    - `grad` : file containing the  squared weight gradient norm after each epoch;  
305
306    - `vargrad` : file containing the variance of the weight gradients after each epoch.      
307
308    """
309    
310    # compress arguments into dictionary 
311    args =compress_args(n,m,L, seed, epochs,func_str,loss_str,meta,nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta)
312
313    # set precision strings 
314    if nint==None: nint=n
315    if mint==None: mint=m  
316    if phase_reduce: mint=0
317
318    # set seed for PRNG 
319    algorithm_globals.random_seed= seed
320    rng = np.random.default_rng(seed=seed)
321
322    # generate circuit and set up as QNN 
323    qc = generate_network(n,m,L, encode=not train_superpos, toggle_IL=True, initial_IL=True,input_Ry=train_superpos, real=real,repeat_params=repeat_params, wrap=False)
324                                                                                        
325    qnn = SamplerQNN(
326                    circuit=qc.decompose(),           
327                    sampler=Sampler(options={"shots": 10000, "seed": algorithm_globals.random_seed}),
328                    input_params=qc.parameters[:n], 
329                    weight_params=qc.parameters[n:],
330                    input_gradients=True
331                )
332              
333    # choose initial weights
334    recovered_k =0
335    if recover_temp:    
336        recover_labels=["weights", "mismatch", "loss", "grad", "vargrad"]
337        recover_paths={}
338        for k in np.arange(100,epochs, step=100):
339            for e in np.arange(len(recover_labels)):
340                file=os.path.join(DIR,"outputs", f"__TEMP{k}_{recover_labels[e]}{vars_to_name_str(args)}.npy")
341                recover_paths[recover_labels[e]]= (file if os.path.isfile(file) else None)
342
343                if recover_labels[e]=="weights" and os.path.isfile(file):
344                    recovered_k=k+1
345                     
346        if not None in list(recover_paths.values):
347            initial_weights=np.load(recover_paths["weights"])
348        else:
349            initial_weights =np.zeros(len(qc.parameters[n:])) # initialise parameters to zero   
350    else:
351        initial_weights =np.zeros(len(qc.parameters[n:])) # initialise parameters to zero   
352           
353    # initialise TorchConnector
354    model = TorchConnector(qnn, initial_weights)
355
356    # choose optimiser 
357    optimizer = Adam(model.parameters(), lr=0.01, betas=(0.7, 0.999), weight_decay=0.005) 
358                
359    # set up arrays to store training outputs 
360    if recover_temp and not None in list(recover_paths.values):
361        mismatch_vals=np.load(recover_paths["mismatch"])
362        loss_vals=np.load(recover_paths["loss"])
363        grad_vals=np.load(recover_paths["grad"])
364        var_grad_vals=np.load(recover_paths["var_grad"])
365    else:    
366        mismatch_vals = np.empty(epochs)
367        loss_vals = np.empty(epochs)
368        grad_vals = np.empty(epochs)
369        var_grad_vals = np.empty(epochs)
370
371    # generate x and f(x) values
372    pn =n - nint
373    pm =m - mint
374
375    if train_superpos:
376
377        # sample all basis states of input register and convert to binary 
378        x_arr = np.arange(0, 2**n)
379        x_arr_bin =[dec_to_bin(i,n,encoding="unsigned mag") for i in x_arr]
380
381        # apply function and reduce to phase value between 0 and 1 
382        fx_arr = [psi(i, mode=func_str) for i in x_arr]
383
384        if phase_reduce:
385            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
386
387        # convert fx_arr to binary at available target register precision 
388        fx_arr_bin = [dec_to_bin(i,m,nint=mint,encoding="unsigned mag") for i in fx_arr]
389
390        if np.max(fx_arr)> 2.**mint - 2.**(-pm) and mint != 0:
391            raise ValueError(f"Insufficient number of target (integer) qubits.")
392        
393        # get bit strings corresponding to target arrays and convert to indices
394        target_bin = [fx_arr_bin[i]+x_arr_bin[i] for i in x_arr]
395        target_ind = [bin_to_dec(i, encoding='unsigned mag') for i in target_bin]
396
397        # prepare target array 
398        target_arr = np.zeros(2**(n+m))
399        for k in target_ind:
400            target_arr[int(k)]=1
401
402    else:        
403        x_min = 0
404        x_max = 2.**nint - 2.**(-pn) 
405        x_arr = np.array(x_min + (x_max - x_min) *rng.random(size=epochs))
406        fx_arr = [psi(i, mode=func_str) for i in x_arr]
407
408        # reduce to phase value between 0 and 1:
409        if phase_reduce: 
410            fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
411        
412        if np.max(fx_arr)> 2.**mint - 2.**(-pm) and mint != 0:
413            raise ValueError(f"Insufficient number of target (integer) qubits.")
414
415    # choose loss function 
416    criterion=set_loss_func(loss_str, args)
417    
418    # start training 
419    print(f"\n\nTraining started. Epochs: {epochs}. Input qubits: {n}. Target qubits: {m}. QCNN layers: {L}. \n")
420    start = time.time() 
421
422    warnings.filterwarnings("ignore", category=UserWarning)
423
424    for i in np.arange(epochs)[recovered_k:]:
425
426        if train_superpos == False:
427            # get input data
428            input = Tensor(binary_to_encode_param(dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint))) 
429
430            # get target data 
431            target_arr = np.zeros(2**(n+m))
432            index = int(dec_to_bin(fx_arr[i],m,'unsigned mag',nint=mint)+dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint),2)
433            target_arr[index]=1 
434            target=Tensor(target_arr)
435        else:
436
437            # generate random coefficients 
438            coeffs = np.array(np.pi / 2 * (1+delta *(2 *rng.random(size=n)-1)))
439            
440            # get input data 
441            input=Tensor(coeffs)
442
443            # get target data 
444            
445            target_ampl = np.empty(2**(n+m))
446
447            for j in np.arange(2**n):
448                for k in np.arange(2**m):
449                    ind =int(dec_to_bin(k,m)+dec_to_bin(j,n),2) 
450                    bin_n = dec_to_bin(j,n)[:: -1] # reverse bit order !
451                    val=1 
452
453                    for l in np.arange(n):
454                        val *= np.cos(coeffs[l]/2) if bin_n[l]=='0' else np.sin(coeffs[l]/2)
455
456                    target_ampl[ind] = val * target_arr[ind] 
457
458            target=Tensor(target_ampl**2)
459
460        # train model  
461        optimizer.zero_grad()
462
463        # apply loss function          
464        if loss_str=="WIM":
465            WIM_weights_arr=np.ones(2**(n+m)) if i==recovered_k else WIM_weights_arr
466            WIM_weights_tensor=Tensor(WIM_weights_arr)
467            loss =criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target), WIM_weights_tensor)    # add small number in sqrt to avoid zero grad !
468        else: 
469            loss = criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target))                       # add small number in sqrt to avoid zero grad !
470
471        # propagate gradients and recompute weights
472        loss.backward()
473        optimizer.step()
474
475        # save loss and grad for plotting 
476        loss_vals[i]=loss.item()
477        grad_vals[i]=np.sum(model.weight.grad.numpy()**2)
478        var_grad_vals[i]=np.std(model.weight.grad.numpy())**2
479        
480        # set up circuit with calculated weights
481        circ = generate_network(n,m,L, encode=not train_superpos, toggle_IL=True, initial_IL=True,input_Ry=train_superpos, real=real,repeat_params=repeat_params, wrap=False)
482        
483        with no_grad():
484            generated_weights = model.weight.detach().numpy()   
485        if train_superpos:
486            input_params = coeffs
487            params=np.concatenate((input_params, generated_weights))  
488        else:
489            input_params = binary_to_encode_param(dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint))
490            params = np.concatenate((input_params, generated_weights))   
491        circ = circ.assign_parameters(params)
492
493        # get statevector 
494        state_vector = get_state_vec(circ)
495
496        # calculate fidelity and mismatch
497        target_state = target_ampl**2 if train_superpos else target_arr 
498        fidelity = np.abs(np.dot(np.sqrt(target_state),np.conjugate(state_vector)))**2
499        mismatch = 1. - np.sqrt(fidelity)
500        mismatch_vals[i]=mismatch
501
502        # set loss func weights for WIM
503        if loss_str=="WIM" and (i % 10 ==0) and (i >=1): WIM_weights_arr= set_WIM_weights(generated_weights, args)
504            
505        # temporarily save outputs every hundred iterations
506        temp_ind = epochs - 100 
507        
508        if recover_temp:
509            temp_ind = recovered_k -1
510
511        if (i % 100 ==0) and (i != 0) and (i != epochs-1): 
512
513            temp_labels=["weights", "mismatch", "loss", "grad", "vargrad"] 
514            temp_arrs=[generated_weights, mismatch_vals, loss_vals, grad_vals, var_grad_vals] 
515
516            for e in np.arange(len(temp_labels)):
517                # save temp file 
518                file=os.path.join(DIR,"outputs",f"__TEMP{i}_{temp_labels[e]}{vars_to_name_str(args)}")
519                np.save(file,temp_arrs[e])
520
521                # delete previous temp file 
522                old_file=os.path.join(DIR,"outputs",f"__TEMP{i-100}_{temp_labels[e]}{vars_to_name_str(args)}.npy")
523                os.remove(old_file) if os.path.isfile(old_file) else None
524
525            # make note of last created temp files
526            temp_ind = i   
527        
528        # print status
529        a = int(20*(i+1)/epochs)
530       
531        if i==recovered_k:
532            time_str="--:--:--.--"
533        elif i==epochs-1:
534            time_str="00:00:00.00"    
535        else:
536            if recover_temp:
537                    remaining = ((time.time() - start) / (i-recovered_k)) * (epochs - i)
538            else:
539                remaining = ((time.time() - start) / i) * (epochs - i)
540            mins, sec = divmod(remaining, 60)
541            hours, mins = divmod(mins, 60)
542            time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}"
543
544        prefix="\t" 
545        print(f"{prefix}[{u'█'*a}{('.'*(20-a))}] {100.*((i+1)/epochs):.2f}% ; Loss {loss_vals[i]:.2e} ; Mismatch {mismatch:.2e} ; ETA {time_str}", end='\r', file=sys.stdout, flush=True)
546        
547        
548    print(" ", flush=True, file=sys.stdout)
549    
550    warnings.filterwarnings("default", category=UserWarning)
551
552    elapsed = time.time()-start
553    mins, sec = divmod(elapsed, 60)
554    hours, mins = divmod(mins, 60)
555    time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}" 
556
557    # decompose circuit for gate count 
558    num_CX = dict(circ.decompose(reps=4).count_ops())["cx"]
559    num_gates = num_CX + dict(circ.decompose(reps=4).count_ops())["u"]
560    print(f"\nTraining completed in {time_str}. Number of weights: {len(generated_weights)}. Number of gates: {num_gates} (of which CX gates: {num_CX}). \n\n")
561
562    # delete temp files
563    temp_labels=["weights", "mismatch", "loss", "grad", "vargrad"]  
564    for i in np.arange(len(temp_labels)):
565        file=os.path.join(DIR,"outputs",f"__TEMP{temp_ind}_{temp_labels[i]}{vars_to_name_str(args)}.npy")
566        os.remove(file) if os.path.isfile(file) else None
567                            
568    # save outputs 
569    with no_grad():
570            generated_weights = model.weight.detach().numpy()
571    outputs= [generated_weights, mismatch_vals, loss_vals, grad_vals, var_grad_vals]
572    output_labels=["weights", "mismatch", "loss", "grad", "vargrad"]  
573    for i in np.arange(len(outputs)):
574        np.save(os.path.join(DIR,"outputs", f"{output_labels[i]}{vars_to_name_str(args)}"), outputs[i])      
575
576    return 0 

Train a QCNN to perform function evaluation $\ket{j}\ket{0} \mapsto \ket{j}\ket{\Psi(j)}$.

The QCNN is generated using pqcprep.pqc_tools.generate_network().

Arguments:

  • n : int

    Number of qubits in the input register.

  • m : int

    Number of qubits in the target register.

  • L : int

    Number of layers in the network.

  • seed : int

    Seed for random number generation.

  • epochs : int

    Number of training runs.

  • func_str : str

    String specifying the function $\Psi$ to be evaluated. Must be a valid option for the argument mode of pqcprep.psi_tools.psi().

  • loss_str : str

    String specifying the loss function minimised by the optimiser. Must be a valid option for the argument loss_str of set_loss_func().

  • meta : str

    String containing meta information to be included in output file names.

  • recover_temp : boolean

    If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten.

  • nint : int

    Number of integer qubits in input register.

  • mint : int

    Number of integer qubits in target register.

  • phase_reduce : boolean

    If True, reduce $\Psi(j)$ to the interval $[0, 2 \pi)$ i.e. perform the mapping $\Psi \to \Psi (\text{mod} \; 2 \pi)$.

  • train_superpos : boolean

    If True, train on a superposition of input states. If False, train on randomly sampled individual input states.

  • real : boolean

    If True, generate a network only involving CX and Ry rotations, resulting in real amplitudes.

  • repeat_params : str, optional

    Keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type. Options are None (do not keep parameters fixed), 'CL' (keep parameters fixed for convolutional layers), 'IL' (keep parameters fixed for input layers), 'both' (keep parameters fixed for both convolutional and input layers).

  • WILL_p : float

    The $p$ parameter of the WILL loss function, as described in set_loss_func().

  • WILL_q : float

    The $q$ parameter of the WILL loss function, as described in set_loss_func().

  • delta : float

    Hyper-parameter controlling the sampling of input state coefficients when training in superposition (train_superpos = True). Must be between 0 and 1. delta = 0 corresponds to coefficients fixed at $\frac{1}{\sqrt{2^n}}$ while delta = 1 corresponds to coefficients randomly assuming values on the range $(0,1)$. Intermediate values of delta result in coefficinets being randomly sampled on an interval around $\frac{1}{\sqrt{2^n}}$, with the range of the interval determined by the value of delta.

  • DIR : str

    Parent directory for output files.

Returns:

The output produced by the training is saved in binary .npy files in the directory DIR/outputs using naming convention <TYPE>_<NAME_STR>.npy where <NAME_STR> is the name string produced by pqcprep.file_tools.vars_to_name_str() and <TYPE> is one of:

  • weights : file containing the QCNN weights determined by the optimiser;

  • loss : file containing the loss value after each epoch;

  • mismatch : file containg the mismatch value after each epoch;

  • grad : file containing the squared weight gradient norm after each epoch;

  • vargrad : file containing the variance of the weight gradients after each epoch.

def test_QNN( n, m, L, seed, epochs, func_str, loss_str, meta, nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta, DIR, verbose=True):
578def test_QNN(n,m,L,seed,epochs, func_str,loss_str,meta,nint,mint,phase_reduce,train_superpos,real,repeat_params,WILL_p, WILL_q,delta,DIR,verbose=True):   
579    """
580    Test performance of a QCNN trained for function evaluation with respect to different metrics. 
581
582    This requires the existence of an appropriate `weights_<NAME_STR>.npy` file (as produced by `train_QNN()`) in the directory `DIR/outputs`, where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()`. 
583
584    Arguments: 
585    ---
586
587    Same arguments as `train_QNN()`. See there for a description. 
588
589    Returns:
590    ---
591
592    The testing output produced is saved in binary `.npy` files in the directory `DIR/ampl_outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
593    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str()` and `<TYPE>` is one of: 
594 
595    - `mismatch_by_state` : file containing the mismatch between the state produced by the network and the desired output state for each of the possible input 
596        register states. Contains a dictionary with the input bit strings as keys and the associated mismatch as values. 
597
598    - `phase` : file containing the phase function encoded by the network when the input register is in an equal superposition of input states. 
599
600    - `metrics` : file containing a dictionary with several metrics evaluating the performance of the network. These metrics are 
601        * `mu` : mean mismatch (mean of the data contained in `mismatch_by_state_<NAME_STR>.npy`); should be zero for ideal performance
602        * `sigma` : mismatch standard deviation (standard deviation of the data contained in `mismatch_by_state_<NAME_STR>.npy`);  should be zero for ideal performance
603        * `eps` : normalisation error on the state vector associated with the data contained in `phase_<NAME_STR>.npy`;  should be zero for ideal performance
604        * `chi` : mean absolute difference between the phase function contained in `phase_<NAME_STR>.npy` and the rounded desired phase function;  should be zero for ideal performance
605        * `omega` : a super-metric defined as `1/(mu + sigma + eps + chi)` ; should be maximal for ideal performance
606
607    """
608    # compress arguments into dictionary 
609    args =compress_args(n,m,L, seed, epochs,func_str,loss_str,meta,nint, mint, phase_reduce, train_superpos, real, repeat_params, WILL_p, WILL_q, delta)
610    name_str=vars_to_name_str(args)  
611
612    # set precision strings 
613    if nint==None: nint=n
614    if mint==None: mint=m  
615    if phase_reduce: mint=0                  
616
617    # load weights 
618    if os.path.isfile(os.path.join(DIR,"outputs",f"weights{name_str}.npy")):
619        weights = np.load(os.path.join(DIR,"outputs",f"weights{name_str}.npy"))
620    else:
621        raise ValueError("No appropriate QCNN weights could be found. Check the network configuration as well as the relevant directory.")    
622
623    # initialise array to store results 
624    mismatch = np.empty(2**n)
625    signs = np.empty(2**n)
626    
627    # iterate over input states 
628    x_arr = np.arange(2**n)
629    fx_arr = [psi(i, mode=func_str) for i in x_arr]
630
631    if phase_reduce: 
632        fx_arr = [np.modf(i/ (2* np.pi))[0] for i in fx_arr]
633
634    for i in x_arr:
635        
636        # prepare circuit 
637        enc=binary_to_encode_param(np.binary_repr(i,n))
638        params=np.concatenate((enc, weights))  
639
640        circ = generate_network(n,m,L, encode=True,toggle_IL=True, real=real,repeat_params=repeat_params)
641        circ = circ.assign_parameters(params) 
642
643        # get target array 
644        target_arr = np.zeros(2**(n+m))
645
646        index = int(dec_to_bin(fx_arr[i],m,'unsigned mag',nint=mint)+dec_to_bin(x_arr[i],n,'unsigned mag',nint=nint),2)
647        target_arr[index]=1 
648
649        # get statevector 
650        state_vector = get_state_vec(circ)
651
652        signs[i]=np.sign(np.sum(np.real(state_vector)*np.sqrt(target_arr)))
653
654        # calculate fidelity and mismatch 
655        fidelity = np.abs(np.dot(np.sqrt(target_arr),np.conjugate(state_vector)))**2
656        mismatch[i] = 1. - np.sqrt(fidelity) 
657            
658    # get phase target 
659    fx_arr_bin = [dec_to_bin(i,m, "unsigned mag", 0) for i in fx_arr]
660    phase_target =  np.array([bin_to_dec(i,"unsigned mag", mint) for i in fx_arr_bin])
661    if phase_reduce:
662        phase_target *= 2 * np.pi 
663
664    # get state vector for full phase extraction 
665    state_vec= full_encode(n,m, weights_A_str=None, weights_p_str=weights, L_A=None, L_p=L,real_p=real,repeat_params=repeat_params,full_state_vec=False, no_UA=True, operators="QRQ")
666    phase = phase_from_state(state_vec)
667
668    # calculate metrics
669    mu = np.mean(mismatch)
670    sigma = np.std(mismatch) 
671    eps = 1 - np.sum(np.abs(state_vec)**2)
672    chi = np.mean(np.abs(phase - phase_target))
673    omega= 1/(mu+sigma+eps+chi) 
674
675    # save outputs
676    full_dic = dict(zip(x_arr, mismatch)) 
677    np.save(os.path.join(DIR,"outputs",f"mismatch_by_state{name_str}.npy"), full_dic) 
678
679    metric_dic = {} 
680    metric_dic["mu"]=mu 
681    metric_dic["sigma"]=sigma   
682    metric_dic["eps"]=eps 
683    metric_dic["chi"]=chi
684    metric_dic["omega"]=omega  
685    np.save(os.path.join(DIR,"outputs",f"metrics{name_str}.npy"), metric_dic) 
686
687    np.save(os.path.join(DIR,"outputs",f"phase{name_str}.npy"), phase) 
688
689    if verbose:
690        print("Mismatch by input state:")
691        for i in x_arr:
692            print(f"\t{np.binary_repr(i,n)}:  {mismatch[i]:.2e} ({signs[i]})")
693        print("-----------------------------------")
694        print(f"Mu: \t{mu:.3e}") 
695        print(f"Sigma: \t{sigma:.3e}") 
696        print(f"Eps: \t{eps:.3e}")
697        print(f"Chi: \t{chi:.3e}") 
698        print(f"Omega: \t{omega:.3f}")
699        print("-----------------------------------")
700        print("")
701        print("")    
702
703    return 0 

Test performance of a QCNN trained for function evaluation with respect to different metrics.

This requires the existence of an appropriate weights_<NAME_STR>.npy file (as produced by train_QNN()) in the directory DIR/outputs, where <NAME_STR> is the name string produced by pqcprep.file_tools.vars_to_name_str().

Arguments:

Same arguments as train_QNN(). See there for a description.

Returns:

The testing output produced is saved in binary .npy files in the directory DIR/ampl_outputs using naming convention <TYPE>_<NAME_STR>.npy where <NAME_STR> is the name string produced by pqcprep.file_tools.vars_to_name_str() and <TYPE> is one of:

  • mismatch_by_state : file containing the mismatch between the state produced by the network and the desired output state for each of the possible input register states. Contains a dictionary with the input bit strings as keys and the associated mismatch as values.

  • phase : file containing the phase function encoded by the network when the input register is in an equal superposition of input states.

  • metrics : file containing a dictionary with several metrics evaluating the performance of the network. These metrics are

    • mu : mean mismatch (mean of the data contained in mismatch_by_state_<NAME_STR>.npy); should be zero for ideal performance
    • sigma : mismatch standard deviation (standard deviation of the data contained in mismatch_by_state_<NAME_STR>.npy); should be zero for ideal performance
    • eps : normalisation error on the state vector associated with the data contained in phase_<NAME_STR>.npy; should be zero for ideal performance
    • chi : mean absolute difference between the phase function contained in phase_<NAME_STR>.npy and the rounded desired phase function; should be zero for ideal performance
    • omega : a super-metric defined as 1/(mu + sigma + eps + chi) ; should be maximal for ideal performance
def ampl_train_QNN( n, L, x_min, x_max, seed, epochs, func_str, loss_str, meta, recover_temp, nint, repeat_params, DIR):
705def ampl_train_QNN(n,L,x_min,x_max,seed, epochs,func_str,loss_str,meta, recover_temp, nint, repeat_params, DIR):
706    """
707
708    Train a QCNN to prepare an amplitude distribution: $\ket{0} \mapsto \sum_j A(j) \ket{j}$.
709
710    The QCNN is generated using `pqcprep.pqc_tools.A_generate_network()`. 
711
712    Arguments:
713    ---
714
715    - **n** : *int* 
716
717        Number of qubits in the register. 
718
719    - **L** : *int* 
720
721        Number of layers in the network. 
722
723    - **x_min** : *float* 
724
725        Minimum of function domain.         
726
727    - **x_max** : *float* 
728
729        Maximum of function domain.   
730
731    - **seed** : *int* 
732
733        Seed for random number generation. 
734
735    - **epochs** : *int* 
736
737        Number of training runs. 
738
739    - **func_str** : *str*
740
741        String specifying the function $A$ to be prepared. Must be a valid option for the argument `mode` of `pqcprep.psi_tools.A()`. 
742
743    - **loss_str** : *str* 
744
745        String specifying the loss function minimised by the optimiser. Must be a valid option for the argument `loss_str` of `set_loss_func()`. 
746
747    - **meta** : *str*
748
749        String containing meta information to be included in output file names. 
750
751    - **recover_temp** : *boolean* 
752
753        If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten. 
754
755    - **nint** : *int*
756
757        Number of integer qubits in the register.  
758
759    - **repeat_params** : *boolean* 
760
761        If True, keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type.
762
763    - **DIR** : *str*
764
765        Parent directory for output files.     
766          
767
768    Returns:
769    ---
770
771    The output produced by the training is saved in binary `.npy` files in the directory `DIR/ampl_outputs` using naming convention `<TYPE>_<NAME_STR>.npy`
772    where `<NAME_STR>` is the name string produced by `pqcprep.file_tools.vars_to_name_str_ampl()` and `<TYPE>` is one of: 
773
774    - `weights` : file containing the QCNN weights determined by the optimiser;
775
776    - `state_vec` : file containing the statevector corresponding to the register after applying the QCNN; 
777
778    - `loss` : file containing the loss value after each training run; 
779
780    - `mismatch` : file containg the mismatch value after each training run.   
781 
782        
783    """
784
785    # compress arguments into dictionary 
786    args=compress_args_ampl(n,L,x_min,x_max,seed, epochs,func_str,loss_str,meta, nint, repeat_params)
787
788    # set seed for PRNG 
789    algorithm_globals.random_seed= seed
790    rng = np.random.default_rng(seed=seed)
791    
792    # generate circuit and set up as QNN 
793    qc = A_generate_network(n,L,repeat_params)
794    qnn = SamplerQNN(
795            circuit=qc.decompose(),            # decompose to avoid data copying (?)
796            sampler=Sampler(options={"shots": 10000, "seed": algorithm_globals.random_seed}),
797            weight_params=qc.parameters, 
798            input_params=[],  
799            input_gradients=False 
800        )
801    
802    # set precision 
803    if nint==None: nint=n   
804    
805    # choose initial weights
806    recovered_k =0
807
808    if recover_temp:    
809        recover_labels=["weights", "mismatch", "loss"]
810        recover_paths={}
811
812        for k in np.arange(100,epochs, step=100):
813            for e in np.arange(len(recover_labels)):
814                file=os.path.join(DIR,"ampl_outputs", f"__TEMP{k}_{recover_labels[e]}{vars_to_name_str_ampl(args)}.npy")
815                recover_paths[recover_labels[e]]= (file if os.path.isfile(file) else None)
816
817                if recover_labels[e]=="weights" and os.path.isfile(file):
818                    recovered_k=k+1      
819        
820        if not None in list(recover_paths.values):
821            initial_weights=np.load(recover_paths["weights"])
822        else:
823            initial_weights =np.zeros(len(qc.parameters)) # initialise parameters to zero   
824    
825    else:
826        initial_weights = np.zeros(len(qc.parameters)) # initialise parameters to zero   
827    
828    # initialise TorchConnector
829    model = TorchConnector(qnn, initial_weights)
830
831    # choose optimiser and loss function 
832    optimizer = Adam(model.parameters(), lr=0.01, betas=(0.7, 0.999), weight_decay=0.005) # Adam optimizer 
833    criterion=set_loss_func(loss_str, args, ampl=True)
834                    
835    # set up arrays to store training outputs 
836    if recover_temp and not None in list(recover_paths.values):
837        mismatch_vals=np.load(recover_paths["mismatch"])
838        loss_vals=np.load(recover_paths["loss"])
839    else:    
840        mismatch_vals = np.empty(epochs)
841        loss_vals = np.empty(epochs)
842
843    # calculate target and normalise 
844    dx = (x_max-x_min)/(2**n)
845    target_arr = np.array([A(i, mode=func_str) for i in np.arange(x_min,x_max, dx)])**2
846    target_arr = target_arr / np.sum(target_arr)
847    
848    # start training 
849    print(f"\n\nTraining started. Epochs: {epochs}. Input qubits: {n}. Function range: [{x_min},{x_max}]. QCNN layers: {L}. \n")
850    start = time.time() 
851
852    warnings.filterwarnings("ignore", category=UserWarning)
853
854    for i in np.arange(epochs)[recovered_k:]:
855
856        # get input data
857        input = Tensor([]) 
858
859        # get target data 
860        target=Tensor(target_arr)
861
862        # train model  
863        optimizer.zero_grad()
864        loss = criterion(torch.sqrt(torch.abs(model(input))+1e-10), torch.sqrt(target)) # adding 1e-10 to prevent taking sqrt(0) ??!!
865        loss.backward()
866        optimizer.step()
867
868        # save loss for plotting 
869        loss_vals[i]=loss.item()
870
871        # set up circuit with calculated weights
872        circ = A_generate_network(n,L, repeat_params)
873
874        with no_grad():
875            generated_weights = model.weight.detach().numpy()
876        
877        circ = circ.assign_parameters(generated_weights)    
878
879        # get statevector 
880        state_vector = get_state_vec(circ)
881        
882        # calculate fidelity and mismatch 
883        fidelity = np.abs(np.dot(np.sqrt(target_arr),np.conjugate(state_vector)))**2
884        mismatch = 1. - np.sqrt(fidelity)
885
886        # save mismatch for plotting 
887        mismatch_vals[i]=mismatch
888
889        # temporarily save outputs every hundred iterations
890        temp_ind = epochs - 100 
891        
892        if recover_temp:
893            temp_ind = recovered_k -1
894
895        if (i % 100 ==0) and (i != 0) and (i != epochs-1): 
896
897            temp_labels=["weights", "mismatch", "loss"] 
898            temp_arrs=[generated_weights, mismatch_vals, loss_vals] 
899
900            for e in np.arange(len(temp_labels)):
901                # save temp file 
902                file=os.path.join(DIR,"ampl_outputs",f"__TEMP{i}_{temp_labels[e]}{vars_to_name_str_ampl(args)}")
903                np.save(file,temp_arrs[e])
904
905                # delete previous temp file 
906                old_file=os.path.join(DIR,"ampl_outputs",f"__TEMP{i-100}_{temp_labels[e]}{vars_to_name_str_ampl(args)}.npy")
907                os.remove(old_file) if os.path.isfile(old_file) else None
908
909            # make note of last created temp files
910            temp_ind = i 
911         
912        # print status
913        a = int(20*(i+1)/epochs)
914
915        if i==recovered_k:
916            time_str="--:--:--.--"
917        elif i==epochs-1:
918            time_str="00:00:00.00"    
919        else:
920            if recover_temp:
921                remaining = ((time.time() - start) / (i-recovered_k)) * (epochs - i)
922            else:
923                remaining = ((time.time() - start) / i) * (epochs - i)
924            mins, sec = divmod(remaining, 60)
925            hours, mins = divmod(mins, 60)
926            time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}"
927
928        prefix="\t" 
929        print(f"{prefix}[{u'█'*a}{('.'*(20-a))}] {100.*((i+1)/epochs):.2f}% ; Loss {loss_vals[i]:.2e} ; Mismatch {mismatch:.2e} ; ETA {time_str}", end='\r', file=sys.stdout, flush=True)
930        
931    warnings.filterwarnings("default", category=UserWarning)
932
933    print(" ", flush=True, file=sys.stdout)
934
935    elapsed = time.time()-start
936    mins, sec = divmod(elapsed, 60)
937    hours, mins = divmod(mins, 60)
938    time_str = f"{int(hours):02}:{int(mins):02}:{sec:05.2f}" 
939
940    # decompose circuit for gate count 
941    num_CX = dict(circ.decompose(reps=4).count_ops())["cx"]
942    num_gates = num_CX + dict(circ.decompose(reps=4).count_ops())["u"]
943
944    print(f"\nTraining completed in {time_str}. Number of weights: {len(generated_weights)}. Number of gates: {num_gates} (of which CX gates: {num_CX}). \n\n")
945
946    # delete temp files
947    temp_labels=["weights", "mismatch", "loss"]  
948    for i in np.arange(len(temp_labels)):
949        file=os.path.join(DIR,"ampl_outputs",f"__TEMP{temp_ind}_{temp_labels[i]}{vars_to_name_str_ampl(args)}.npy")
950        os.remove(file) if os.path.isfile(file) else None             
951
952    # save outputs 
953    with no_grad():
954            generated_weights = model.weight.detach().numpy()
955    outputs= [generated_weights, mismatch_vals, loss_vals, state_vector]
956    output_labels=["weights", "mismatch", "loss", "statevec"]  
957    for i in np.arange(len(outputs)):
958        np.save(os.path.join(DIR,"ampl_outputs", f"{output_labels[i]}{vars_to_name_str_ampl(args)}"), outputs[i])         
959
960    return 0

Train a QCNN to prepare an amplitude distribution: $\ket{0} \mapsto \sum_j A(j) \ket{j}$.

The QCNN is generated using pqcprep.pqc_tools.A_generate_network().

Arguments:

  • n : int

    Number of qubits in the register.

  • L : int

    Number of layers in the network.

  • x_min : float

    Minimum of function domain.

  • x_max : float

    Maximum of function domain.

  • seed : int

    Seed for random number generation.

  • epochs : int

    Number of training runs.

  • func_str : str

    String specifying the function $A$ to be prepared. Must be a valid option for the argument mode of pqcprep.psi_tools.A().

  • loss_str : str

    String specifying the loss function minimised by the optimiser. Must be a valid option for the argument loss_str of set_loss_func().

  • meta : str

    String containing meta information to be included in output file names.

  • recover_temp : boolean

    If True, continue training from TEMP files (should they exist). If False and TEMP files exist they will be overwritten.

  • nint : int

    Number of integer qubits in the register.

  • repeat_params : boolean

    If True, keep parameters fixed for different layer types, i.e. use the same parameter values for each instance of a layer type.

  • DIR : str

    Parent directory for output files.

Returns:

The output produced by the training is saved in binary .npy files in the directory DIR/ampl_outputs using naming convention <TYPE>_<NAME_STR>.npy where <NAME_STR> is the name string produced by pqcprep.file_tools.vars_to_name_str_ampl() and <TYPE> is one of:

  • weights : file containing the QCNN weights determined by the optimiser;

  • state_vec : file containing the statevector corresponding to the register after applying the QCNN;

  • loss : file containing the loss value after each training run;

  • mismatch : file containg the mismatch value after each training run.