|
from cmath import cos |
|
from inspect import getargs |
|
import logging |
|
import os |
|
import random |
|
from datetime import datetime |
|
import bisect |
|
import copy |
|
from sched import scheduler |
|
import numpy as np |
|
import torch |
|
import torch.backends.cudnn as cudnn |
|
from torch import optim |
|
from torch.cuda.amp import GradScaler |
|
import faulthandler |
|
import pathlib |
|
import argparse |
|
import time |
|
|
|
try: |
|
import wandb |
|
except ImportError: |
|
wandb = None |
|
|
|
try: |
|
import torch.utils.tensorboard as tensorboard |
|
except ImportError: |
|
tensorboard = None |
|
|
|
try: |
|
import horovod.torch as hvd |
|
except ImportError: |
|
hvd = None |
|
|
|
from open_clip import create_model_and_transforms, trace_model, create_model |
|
from training.data import get_data |
|
from training.params import parse_args |
|
from training.distributed import is_master, init_distributed_device, world_info_from_env |
|
from training.logger import setup_logging |
|
from training.scheduler import cosine_lr |
|
from training.lp_train import train_one_epoch, evaluate |
|
from open_clip.utils import get_tar_path_from_dataset_name, dataset_split, get_optimizer |
|
from open_clip.utils import load_p, load_class_label |
|
from open_clip.linear_probe import LinearProbe |
|
|
|
|
|
def maintain_ckpts(args, startidx, all_idx_len): |
|
for i in reversed(range(startidx, all_idx_len)): |
|
if os.path.exists(os.path.join(args.checkpoint_path, f"epoch_top_{i}.pt")): |
|
os.rename( |
|
os.path.join(args.checkpoint_path, f"epoch_top_{i}.pt"), |
|
os.path.join(args.checkpoint_path, f"epoch_top_{i+1}.pt"), |
|
) |
|
if os.path.exists( |
|
os.path.join(args.checkpoint_path, f"epoch_top_{all_idx_len}.pt") |
|
): |
|
os.remove(os.path.join(args.checkpoint_path, f"epoch_top_{all_idx_len}.pt")) |
|
return |
|
|
|
|
|
def update_top_k_performance( |
|
new_metrics_inputs, current_top_k_ckpt_metrics, args, ckpt, bignumbetter=True |
|
): |
|
""" |
|
Record the top-k performance of the current epoch. |
|
current_top_k_metrics is a dictionary of the form: {1: top_1_ckpt_measure, 2: top_2_ckpt_measure, ...} |
|
""" |
|
if isinstance(new_metrics_inputs, (list, tuple)): |
|
new_metrics_inputs = np.mean(new_metrics_inputs) |
|
return update_top_k_performance( |
|
new_metrics_inputs, |
|
current_top_k_ckpt_metrics, |
|
args=args, |
|
ckpt=ckpt, |
|
bignumbetter=bignumbetter, |
|
) |
|
elif isinstance(new_metrics_inputs, dict): |
|
new_metrics_inputs = np.mean(list(new_metrics_inputs.values())) |
|
return update_top_k_performance( |
|
new_metrics_inputs, |
|
current_top_k_ckpt_metrics, |
|
args=args, |
|
ckpt=ckpt, |
|
bignumbetter=bignumbetter, |
|
) |
|
elif isinstance(new_metrics_inputs, (float, int)): |
|
update_flag = {k: False for k in current_top_k_ckpt_metrics.keys()} |
|
sorted_keys = sorted(current_top_k_ckpt_metrics.keys()) |
|
sorted_values = sorted( |
|
current_top_k_ckpt_metrics.values(), reverse=bignumbetter |
|
) |
|
sorted_values_ = copy.deepcopy(sorted_values) |
|
sorted_values.append(new_metrics_inputs) |
|
sorted_values = sorted(sorted_values, reverse=bignumbetter) |
|
sorted_values = sorted_values[:-1] |
|
|
|
if sorted_values == sorted_values_: |
|
return current_top_k_ckpt_metrics, new_metrics_inputs |
|
else: |
|
for i in range(len(sorted_keys)): |
|
if current_top_k_ckpt_metrics[sorted_keys[i]] != sorted_values[i]: |
|
current_top_k_ckpt_metrics[sorted_keys[i]] = sorted_values[i] |
|
update_flag[sorted_keys[i]] = True |
|
for i in range(len(update_flag)): |
|
if update_flag[i]: |
|
maintain_ckpts(args, i, len(sorted_keys)) |
|
torch.save( |
|
ckpt, |
|
os.path.join(args.checkpoint_path, f"epoch_top_{i}.pt"), |
|
) |
|
break |
|
return current_top_k_ckpt_metrics, new_metrics_inputs |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_pretrained_params(n): |
|
return ( |
|
n.startswith("clap_model.transformer") |
|
or n in ["clap_model.positional_embedding", "clap_model.text_projection"] |
|
or n.startswith("clap_model.token_embedding") |
|
or n.startswith("clap_model.ln_final") |
|
or n.startswith("clap_model.logit_scale_t") |
|
) |
|
|
|
|
|
def random_seed(seed=42, rank=0): |
|
torch.manual_seed(seed + rank) |
|
np.random.seed(seed + rank) |
|
random.seed(seed + rank) |
|
|
|
|
|
def config_lp_optimizer(model, data, args): |
|
|
|
if args.optimizer == "adam": |
|
args.wd = 0 |
|
args.wd_pretrained = 0 |
|
args.wd_new = 0 |
|
|
|
in_clap = lambda n, p: n.startswith("clap_model") |
|
|
|
named_parameters = list(model.named_parameters()) |
|
|
|
optimizer = {} |
|
scheduler = {} |
|
|
|
|
|
text_freeze_parameters = [ |
|
p |
|
for n, p in named_parameters |
|
if n.startswith("clap_model.transformer") |
|
or n in ["clap_model.positional_embedding", "clap_model.text_projection"] |
|
or n.startswith("clap_model.token_embedding") |
|
or n.startswith("clap_model.ln_final") |
|
] |
|
|
|
if args.freeze_text: |
|
logging.info("Freeze Text!!!!") |
|
for k in text_freeze_parameters: |
|
k.requires_grad = False |
|
|
|
if not args.lp_freeze: |
|
exclude = ( |
|
lambda n, p: p.ndim < 2 |
|
or "bn" in n |
|
or "ln" in n |
|
or "bias" in n |
|
or "logit_scale" in n |
|
) |
|
include = lambda n, p: not exclude(n, p) |
|
|
|
|
|
|
|
gain_or_bias_params = [ |
|
p for n, p in named_parameters if exclude(n, p) and p.requires_grad |
|
] |
|
|
|
rest_params = [ |
|
p for n, p in named_parameters if include(n, p) and p.requires_grad |
|
] |
|
|
|
if args.train_data is None: |
|
optimizer = None |
|
scheduler = None |
|
else: |
|
total_steps = data["train"].dataloader.num_batches * args.epochs |
|
|
|
if args.split_opt: |
|
for x in ["lr", "beta1", "beta2", "eps", "wd"]: |
|
for y in ["_new", "_pretrained"]: |
|
if getattr(args, x + y) is None: |
|
setattr(args, x + y, getattr(args, x)) |
|
|
|
gain_or_bias_pretrained_params = [ |
|
p |
|
for n, p in named_parameters |
|
if (exclude(n, p) and p.requires_grad) and is_pretrained_params(n) |
|
] |
|
rest_pretrained_params = [ |
|
p |
|
for n, p in named_parameters |
|
if (include(n, p) and p.requires_grad) and is_pretrained_params(n) |
|
] |
|
gain_or_bias_new_params = [ |
|
p |
|
for n, p in named_parameters |
|
if (exclude(n, p) and p.requires_grad) |
|
and (not is_pretrained_params(n)) |
|
] |
|
rest_new_params = [ |
|
p |
|
for n, p in named_parameters |
|
if (include(n, p) and p.requires_grad) |
|
and (not is_pretrained_params(n)) |
|
] |
|
|
|
pretrained_params_optimizer = get_optimizer( |
|
[ |
|
{"params": gain_or_bias_pretrained_params, "weight_decay": 0.0}, |
|
{ |
|
"params": rest_pretrained_params, |
|
"weight_decay": args.wd_pretrained, |
|
}, |
|
], |
|
lr=args.lr_pretrained, |
|
betas=(args.beta1_pretrained, args.beta2_pretrained), |
|
eps=args.eps_pretrained, |
|
momentum=args.momentum_pretrained, |
|
optimizer_name=args.optimizer, |
|
) |
|
pretrained_params_scheduler = cosine_lr( |
|
pretrained_params_optimizer, |
|
args.lr_pretrained, |
|
args.warmup, |
|
total_steps, |
|
) |
|
|
|
new_params_optimizer = get_optimizer( |
|
[ |
|
{"params": gain_or_bias_new_params, "weight_decay": 0.0}, |
|
{"params": rest_new_params, "weight_decay": args.wd_new}, |
|
], |
|
lr=args.lr_new, |
|
betas=(args.beta1_new, args.beta2_new), |
|
eps=args.eps_new, |
|
momentum=args.momentum_new, |
|
optimizer_name=args.optimizer, |
|
) |
|
new_params_scheduler = cosine_lr( |
|
new_params_optimizer, args.lr_new, args.warmup, total_steps |
|
) |
|
|
|
optimizer["text"] = pretrained_params_optimizer |
|
optimizer["audio"] = new_params_optimizer |
|
scheduler["text"] = pretrained_params_scheduler |
|
scheduler["audio"] = new_params_scheduler |
|
|
|
if args.horovod: |
|
pretrained_params_optimizer = hvd.DistributedOptimizer( |
|
pretrained_params_optimizer, |
|
named_parameters=model.named_parameters(), |
|
) |
|
new_params_optimizer = hvd.DistributedOptimizer( |
|
new_params_optimizer, named_parameters=model.named_parameters() |
|
) |
|
hvd.broadcast_parameters(model.state_dict(), root_rank=0) |
|
hvd.broadcast_optimizer_state( |
|
pretrained_params_optimizer, root_rank=0 |
|
) |
|
hvd.broadcast_optimizer_state(new_params_optimizer, root_rank=0) |
|
else: |
|
optimizer["clap"] = get_optimizer( |
|
[ |
|
{"params": gain_or_bias_params, "weight_decay": 0.0}, |
|
{"params": rest_params, "weight_decay": args.wd}, |
|
], |
|
lr=args.lr, |
|
betas=(args.beta1, args.beta2), |
|
eps=args.eps, |
|
momentum=args.momentum, |
|
optimizer_name=args.optimizer, |
|
) |
|
scheduler["clap"] = cosine_lr( |
|
optimizer["clap"], args.lr, args.warmup, total_steps |
|
) |
|
|
|
if args.horovod: |
|
optimizer["clap"] = hvd.DistributedOptimizer( |
|
optimizer["clap"], named_parameters=model.named_parameters() |
|
) |
|
hvd.broadcast_parameters(model.state_dict(), root_rank=0) |
|
hvd.broadcast_optimizer_state(optimizer["clap"], root_rank=0) |
|
|
|
|
|
else: |
|
lp_params = [ |
|
p for n, p in named_parameters if (not in_clap(n, p)) and p.requires_grad |
|
] |
|
lp_optim = get_optimizer( |
|
lp_params, |
|
lr=args.lp_lr, |
|
betas=(args.beta1, args.beta2), |
|
eps=args.eps, |
|
momentum=0.9, |
|
optimizer_name=args.optimizer, |
|
) |
|
optimizer["lp"] = lp_optim |
|
|
|
return optimizer, scheduler, text_freeze_parameters |
|
|
|
|
|
def main(): |
|
args = parse_args() |
|
|
|
time.sleep(args.sleep) |
|
|
|
|
|
args.amodel = args.amodel.replace("/", "-") |
|
|
|
|
|
|
|
|
|
|
|
|
|
random.seed(args.seed) |
|
torch.manual_seed(args.seed) |
|
torch.cuda.manual_seed(args.seed) |
|
torch.cuda.manual_seed_all(args.seed) |
|
np.random.seed(args.seed) |
|
args.class_index_dict = load_class_label(args.class_label_path) |
|
|
|
|
|
if args.name is None: |
|
args.name = "-".join( |
|
[ |
|
datetime.now().strftime("%Y_%m_%d-%H_%M_%S"), |
|
f"linear_probe" f"model_{args.amodel}", |
|
f"lr_{args.lr}", |
|
f"b_{args.batch_size}", |
|
f"j_{args.workers}", |
|
f"p_{args.precision}", |
|
] |
|
) |
|
|
|
|
|
args.distributed = False |
|
args.local_rank, args.rank, args.world_size = world_info_from_env() |
|
|
|
if args.remotedata and is_master(args): |
|
for dataset_name in args.datasetnames: |
|
for split in dataset_split[dataset_name]: |
|
if not os.path.exists(f"./json_files/{dataset_name}/{split}"): |
|
os.makedirs(f"./json_files/{dataset_name}/{split}") |
|
os.system( |
|
f"aws s3 cp s3://s-laion-audio/webdataset_tar/{dataset_name}/{split}/sizes.json ./json_files/{dataset_name}/{split}/sizes.json" |
|
) |
|
|
|
args.log_path = None |
|
if is_master(args, local=args.log_local): |
|
log_base_path = os.path.join(args.logs, args.name) |
|
os.makedirs(log_base_path, exist_ok=True) |
|
log_filename = f"out-{args.rank}" if args.log_local else "out.log" |
|
args.log_path = os.path.join(log_base_path, log_filename) |
|
|
|
|
|
postfix = 0 |
|
while os.path.exists(args.log_path): |
|
postfix += 1 |
|
log_base_path_new = log_base_path + "-" + str(postfix) |
|
os.makedirs(log_base_path_new, exist_ok=True) |
|
log_filename = f"out-{args.rank}" if args.log_local else "out.log" |
|
args.log_path = os.path.join(log_base_path_new, log_filename) |
|
|
|
|
|
|
|
|
|
|
|
|
|
args.log_level = logging.DEBUG if args.debug else logging.INFO |
|
setup_logging(args.log_path, args.log_level) |
|
|
|
|
|
device = init_distributed_device(args) |
|
|
|
args.wandb = "wandb" in args.report_to or "all" in args.report_to |
|
args.tensorboard = "tensorboard" in args.report_to or "all" in args.report_to |
|
if is_master(args): |
|
args.tensorboard_path = ( |
|
os.path.join(args.logs, args.name, "tensorboard") |
|
if args.tensorboard |
|
else "" |
|
) |
|
args.checkpoint_path = os.path.join(args.logs, args.name, "checkpoints") |
|
for dirname in [args.tensorboard_path, args.checkpoint_path]: |
|
if dirname: |
|
os.makedirs(dirname, exist_ok=True) |
|
else: |
|
args.tensorboard_path = "" |
|
args.checkpoint_path = "" |
|
|
|
if args.copy_codebase: |
|
copy_codebase(args) |
|
|
|
assert args.precision in ["amp", "fp16", "fp32"] |
|
if args.precision == "fp16": |
|
logging.warning( |
|
"It is recommended to use AMP mixed-precision instead of FP16. " |
|
"FP16 support needs further verification and tuning, especially for train." |
|
) |
|
|
|
if args.horovod: |
|
logging.info( |
|
f"Running in horovod mode with multiple processes / nodes. Device: {args.device}." |
|
f"Process (global: {args.rank}, local {args.local_rank}), total {args.world_size}." |
|
) |
|
elif args.distributed: |
|
logging.info( |
|
f"Running in distributed mode with multiple processes. Device: {args.device}." |
|
f"Process (global: {args.rank}, local {args.local_rank}), total {args.world_size}." |
|
) |
|
else: |
|
logging.info(f"Running with a single process. Device {args.device}.") |
|
|
|
logging.info(f"openai cache dir: {os.path.expanduser(args.openai_model_cache_dir)}") |
|
|
|
|
|
clap_model, clap_model_cfg = create_model( |
|
args.amodel, |
|
args.tmodel, |
|
args.pretrained, |
|
precision=args.precision, |
|
device=device, |
|
jit=args.torchscript, |
|
force_quick_gelu=args.force_quick_gelu, |
|
openai_model_cache_dir=os.path.expanduser(args.openai_model_cache_dir), |
|
skip_params=False, |
|
pretrained_audio=args.pretrained_audio, |
|
pretrained_text=args.pretrained_text, |
|
enable_fusion=args.enable_fusion, |
|
fusion_type=args.fusion_type, |
|
) |
|
|
|
args.lp_out_ch = len(list(args.class_index_dict.keys())) |
|
|
|
logging.info(f"linear probe using mlp: {args.lp_mlp}") |
|
logging.info(f"linear probe using freeze: {args.lp_freeze}") |
|
logging.info(f"linear probe act layer: {args.lp_act}") |
|
logging.info(f"linear probe out ch: {args.lp_out_ch}") |
|
logging.info(f"linear probe learning rate (if applicable): {args.lp_lr}") |
|
logging.info(f"linear probe loss func: {args.lp_loss}") |
|
logging.info(f"linear probe lp_metrics: {args.lp_metrics}") |
|
|
|
model = LinearProbe( |
|
clap_model, |
|
mlp=args.lp_mlp, |
|
freeze=args.lp_freeze, |
|
in_ch=512, |
|
out_ch=args.lp_out_ch, |
|
act=args.lp_act, |
|
) |
|
model = model.to(device) |
|
|
|
if args.horovod: |
|
with torch.no_grad(): |
|
for param in model.parameters(): |
|
param.set_(param.contiguous()) |
|
|
|
if args.trace: |
|
model = trace_model(model, batch_size=args.batch_size, device=device) |
|
|
|
if is_master(args): |
|
logging.info("Linear Probe CLAP Model:") |
|
logging.info(f"{str(clap_model)}") |
|
logging.info("Params:") |
|
params_file = os.path.join(args.logs, args.name, "params.txt") |
|
with open(params_file, "w") as f: |
|
for name in sorted(vars(args)): |
|
val = getattr(args, name) |
|
logging.info(f" {name}: {val}") |
|
f.write(f"{name}: {val}\n") |
|
|
|
if args.distributed and not args.horovod: |
|
if args.use_bn_sync: |
|
model = torch.nn.SyncBatchNorm.convert_sync_batchnorm(model) |
|
ddp_args = {} |
|
if args.ddp_static_graph: |
|
|
|
ddp_args["static_graph"] = True |
|
model = torch.nn.parallel.DistributedDataParallel( |
|
model, device_ids=[device], find_unused_parameters=True, **ddp_args |
|
) |
|
|
|
data = get_data(args, clap_model_cfg) |
|
assert len(data), "At least one train or eval dataset must be specified." |
|
if args.trace: |
|
assert "train" not in data, "Cannot train with traced model" |
|
|
|
optimizer, scheduler, text_freeze_parameters = config_lp_optimizer( |
|
model, data, args |
|
) |
|
|
|
scaler = GradScaler() if args.precision == "amp" else None |
|
|
|
|
|
start_epoch = 0 |
|
if args.resume is not None: |
|
if os.path.isfile(args.resume): |
|
checkpoint = torch.load(args.resume, map_location=device) |
|
if "epoch" in checkpoint: |
|
|
|
start_epoch = checkpoint["epoch"] |
|
sd = checkpoint["state_dict"] |
|
if not args.distributed and next(iter(sd.items()))[0].startswith( |
|
"module" |
|
): |
|
sd = {k[len("module.") :]: v for k, v in sd.items()} |
|
model.load_state_dict(sd) |
|
if args.split_opt: |
|
if optimizer is not None: |
|
for k, o_ in optimizer.items(): |
|
o_.load_state_dict(checkpoint[k + "_" + "optimizer"]) |
|
if optimizer is not None: |
|
optimizer.load_state_dict(checkpoint["optimizer"]) |
|
if scaler is not None and "scaler" in checkpoint: |
|
scaler.load_state_dict(checkpoint["scaler"]) |
|
logging.info( |
|
f"=> resuming checkpoint '{args.resume}' (epoch {start_epoch})" |
|
) |
|
else: |
|
|
|
model.load_state_dict(checkpoint) |
|
logging.info( |
|
f"=> loaded checkpoint '{args.resume}' (epoch {start_epoch})" |
|
) |
|
if args.freeze_text: |
|
print("Freeze Text!!!!") |
|
for k in text_freeze_parameters: |
|
k.requires_grad = False |
|
else: |
|
logging.info("=> no checkpoint found at '{}'".format(args.resume)) |
|
|
|
cudnn.benchmark = True |
|
cudnn.deterministic = False |
|
|
|
|
|
args.save_logs = args.logs and args.logs.lower() != "none" and is_master(args) |
|
writer = None |
|
if args.save_logs and args.tensorboard: |
|
assert tensorboard is not None, "Please install tensorboard." |
|
writer = tensorboard.SummaryWriter(args.tensorboard_path) |
|
|
|
if args.wandb and is_master(args): |
|
assert wandb is not None, "Please install wandb." |
|
logging.debug("Starting wandb.") |
|
args.train_sz = data["train"].dataloader.num_samples |
|
if args.val_data is not None: |
|
args.val_sz = data["val"].dataloader.num_samples |
|
|
|
wandb.init( |
|
project="clap", |
|
notes=args.wandb_notes, |
|
name=args.wandb_notes, |
|
tags=[], |
|
config=vars(args), |
|
) |
|
if args.debug: |
|
wandb.watch(model, log="all") |
|
wandb.save(params_file) |
|
logging.debug("Finished loading wandb.") |
|
|
|
if "train" not in data: |
|
evaluate(model, data, start_epoch, args, writer) |
|
return |
|
elif start_epoch == 0 and "val" in data and not args.no_eval: |
|
evaluate(model, data, 0, args, writer) |
|
if args.save_top_performance: |
|
current_top_k_ckpt_metrics = { |
|
i: 0 for i in range(args.save_top_performance) |
|
} |
|
|
|
for epoch in range(start_epoch, args.epochs): |
|
|
|
if epoch == args.freeze_text_after: |
|
print("Text pretrained parameters are freezed since this epoch.") |
|
for k in text_freeze_parameters: |
|
k.requires_grad = False |
|
if is_master(args): |
|
logging.info(f"Start epoch {epoch}") |
|
|
|
train_one_epoch(model, data, epoch, optimizer, scaler, scheduler, args, writer) |
|
completed_epoch = epoch + 1 |
|
|
|
if ( |
|
any(v in data for v in ("val", "imagenet-val", "imagenet-v2")) |
|
and not args.no_eval |
|
): |
|
metrics = evaluate(model, data, completed_epoch, args, writer) |
|
if args.save_top_performance: |
|
top_k_dataset = args.top_k_checkpoint_select_dataset |
|
top_k_metric = args.top_k_checkpoint_select_metric |
|
filtered_metrics = [ |
|
v |
|
for k, v in metrics.items() |
|
if top_k_metric in k and top_k_dataset in k |
|
] |
|
|
|
if args.save_logs: |
|
opt_dict = { |
|
k + "_" + "optimizer": v.state_dict() for k, v in optimizer.items() |
|
} |
|
checkpoint_dict = { |
|
"epoch": completed_epoch, |
|
"name": args.name, |
|
"state_dict": model.state_dict(), |
|
} |
|
checkpoint_dict.update(opt_dict) |
|
if scaler is not None: |
|
checkpoint_dict["scaler"] = scaler.state_dict() |
|
|
|
if completed_epoch == args.epochs or ( |
|
args.save_frequency > 0 and (completed_epoch % args.save_frequency) == 0 |
|
): |
|
torch.save( |
|
checkpoint_dict, |
|
os.path.join(args.checkpoint_path, f"epoch_{completed_epoch}.pt"), |
|
) |
|
if args.save_most_recent: |
|
torch.save( |
|
checkpoint_dict, |
|
os.path.join(args.checkpoint_path, f"epoch_latest.pt"), |
|
) |
|
if args.save_top_performance and not args.no_eval: |
|
update_top_k_performance( |
|
filtered_metrics, |
|
current_top_k_ckpt_metrics, |
|
args, |
|
checkpoint_dict, |
|
bignumbetter=True, |
|
) |
|
|
|
if args.wandb and is_master(args): |
|
wandb.finish() |
|
|
|
|
|
def copy_codebase(args): |
|
from shutil import copytree, ignore_patterns |
|
|
|
new_code_path = os.path.join(args.logs, args.name, "code") |
|
if os.path.exists(new_code_path): |
|
print( |
|
f"Error. Experiment already exists at {new_code_path}. Use --name to specify a new experiment." |
|
) |
|
return -1 |
|
print(f"Copying codebase to {new_code_path}") |
|
current_code_path = os.path.realpath(__file__) |
|
for _ in range(3): |
|
current_code_path = os.path.dirname(current_code_path) |
|
copytree( |
|
current_code_path, new_code_path, ignore=ignore_patterns("log", "logs", "wandb") |
|
) |
|
print("Done copying code.") |
|
return 1 |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |
|
|