對比學習 ——simsiam 代碼解析。

 目錄

1 : 事先準備 。

2 : 代碼閱讀。 

2.1: 數據讀取 

2.2: 模型載入 

3 訓練過程: 

4 測試過程:

5 :線性驗證

6 : 用自己數據集進行對比學習。 

第一:  改數據集 :

 

2 改變batch_size和圖片大小。 


 

 

   寫在前面的話 CSDN真的是’sb’中的’sb’軟件, 辛辛苦苦寫半天  我複製個東西過來 他就把前面的刷沒了 還要我重頭寫????????????神經並b 

——————————————————————————————————————————

 

2022李宏毅作業HW3 是食物的分類 ,但是我怎麼嘗試 再監督學習的模式下 準確率都達不到百分之60 .。半監督也感覺效果不明顯。 所以 這次就想着對比學習能不能用來解決這個問題呢 。?看了一圈,感覺simsiam是對比學習里比較簡單的一種方法,好像效果也不錯。 所以來看一看這個東西是怎麼玩的。

        simsaim 是對比學習很新的文章了。 他的訓練方式簡單來說就是 ,一張圖片 ,用不同的方式去增廣後形成圖片對 。 然後用一張去預測另一張。 不懂得可以看朱老師的視頻。 

對比學習論文綜述【論文精讀】_嗶哩嗶哩_bilibili

1 : 事先準備 。

        代碼地址 : 好像不是官方的

下載解壓。 

直接在main函數的 運行 編輯配置中輸入

--data_dir ../Data/ --log_dir ../logs/ -c configs/simsiam_cifar.yaml --ckpt_dir ~/.cache/ --hide_progress --download

os.environ['CUDA_VISIBLE_DEVICES']='0'

注意 : 第二次運行可以刪掉download

2 : 代碼閱讀。 

        神經網絡的一個基本的框架就是 : 數據讀取 , 模型載入, 訓練,測試。 我們接下來根據這四塊來看。 

2.1: 數據讀取 

運行main文件 。 

    main(device=args.device, args=args)

進入main 函數 。 

是三個數據集的讀取。 

 

train_loader ,, memory_loader 和 test_loader。 train和memory 都是訓練集的數據 他們的不同之處在於, 數據增廣的方式不同。 train的增廣是用來訓練的 memory和test的增廣都是用來測試的。由於在對比學習里,  數據增廣是很重要的 ,所以這裡看下數據增廣的方式。 
        dataset=get_dataset(
            transform=get_aug(train=True, **args.aug_kwargs),
            train=True,
            **args.dataset_kwargs),

**args.aug_kwargs 里規定了圖片大小是32. 以及這次用的是simsaim。 

這裡兩個train  。 只有訓練集的第一個train是true。 而訓練集的增廣方式如下 

 

class SimSiamTransform():
    def __init__(self, image_size, mean_std=imagenet_mean_std):
        image_size = 224 if image_size is None else image_size # by default simsiam use image size 224
        p_blur = 0.5 if image_size > 32 else 0 # exclude cifar
        # the paper didn't specify this, feel free to change this value
        # I use the setting from simclr which is 50% chance applying the gaussian blur
        # the 32 is prepared for cifar training where they disabled gaussian blur
        self.transform = T.Compose([
            T.RandomResizedCrop(image_size, scale=(0.2, 1.0)),
            T.RandomHorizontalFlip(),
            T.RandomApply([T.ColorJitter(0.4,0.4,0.4,0.1)], p=0.8),
            T.RandomGrayscale(p=0.2),
            T.RandomApply([T.GaussianBlur(kernel_size=image_size//20*2+1, sigma=(0.1, 2.0))], p=p_blur),
            T.ToTensor(),
            T.Normalize(*mean_std)
        ])
    def __call__(self, x):
        x1 = self.transform(x)
        x2 = self.transform(x)
        return x1, x2 

   增廣方式可以參考 官網 Transforming and augmenting images — Torchvision 0.12 documentation

這裡依次是 : 隨機resize 然後剪切為輸入大小,  也就是會隨機取圖片里的一塊。

                       隨機水平變換

                        0.8的概率調節亮度對比度和飽和度。

                        0.2概率灰度化

                        對於32的照片 不做高斯模糊。

                        轉化為張量並標準化。 

然後 對於一個輸入  這裡會做兩次transform  call可以讓這個類像函數那樣被調用。 

 對於 測試用的訓練集 。也就是memory 是下面的增廣方式。  而test也是下面的增廣方式 。

        else:
            self.transform = transforms.Compose([
                transforms.Resize(int(image_size*(8/7)), interpolation=Image.BICUBIC), # 224 -> 256 
                transforms.CenterCrop(image_size),
                transforms.ToTensor(),
                transforms.Normalize(*normalize)
            ])

              如果輸入是 224 就 先放大到256,然後中心裁剪224,之後標準化。 

                如果輸入是32 就放大到36 再中心裁剪32 .後標準化。 

用的是cifar10的數據。 其實也就相當於很普通的 讀圖片  然後增廣, 加標籤。 

我們只要看getitem取出來的數據是什麼就好 。

        img, target = self.data[index], self.targets[index]

        # doing this so that it is consistent with all other datasets
        # to return a PIL Image
        img = Image.fromarray(img)

        if self.transform is not None:
            img = self.transform(img)

        if self.target_transform is not None:
            target = self.target_transform(target)

        return img, target

 

​ 注意  如果是訓練集 在trans時會返回兩張圖片 ,所以返回的是一個元組。 而測試時 ,img就是單獨的一張圖片。 target也就是標籤。 

 

 總結:數據部分 我們需要做一個數據集, 然後訓練集的增廣要返回兩個結果。  當讀取數據時,返回的是圖片數據和標籤數據。 

        

2.2: 模型載入 

        這一部分我們來看模型 ,我們可以根據下面的偽代碼來看模型長什麼樣子。 偽代碼非常容易看懂。 aug就是增廣嘛。 f來提特徵,然後兩個預測。 算loss 回傳。 

 

    model = get_model(args.model).to(device)

這句來獲得模型。 

    if model_cfg.name == 'simsiam':
        model =  SimSiam(get_backbone(model_cfg.backbone))

backbone就是普通的res18  這裡不需要預訓練的模型 只需要初始模型 。 

class SimSiam(nn.Module):
    def __init__(self, backbone=resnet50()):
        super().__init__()
        
        self.backbone = backbone
        self.projector = projection_MLP(backbone.output_dim)

        self.encoder = nn.Sequential( # f encoder
            self.backbone,
            self.projector
        )
        self.predictor = prediction_MLP()
    
    def forward(self, x1, x2):

        f, h = self.encoder, self.predictor
        z1, z2 = f(x1), f(x2)
        p1, p2 = h(z1), h(z2)
        L = D(p1, z2) / 2 + D(p2, z1) / 2
        return {'loss': L}

這個就是simsam的模型了 。 projector 是個三層的普通mlp 。 encoder 就是偽代碼里的f了 而predictor就是偽代碼里的h了 。 我們具體來看下loss 。 

def D(p, z, version='simplified'): # negative cosine similarity
    if version == 'original':
        z = z.detach() # stop gradient
        p = F.normalize(p, dim=1) # l2-normalize 
        z = F.normalize(z, dim=1) # l2-normalize 
        return -(p*z).sum(dim=1).mean()

    elif version == 'simplified':# same thing, much faster. Scroll down, speed test in __main__
        return - F.cosine_similarity(p, z.detach(), dim=-1).mean()
    else:
        raise Exception

 傳說 simsaim的精髓就在於這個loss, 在於這個z.detach 也就是傳說中的stop gradiant。 有了這個梯度停止, simsaim才能夠訓練的起來。 這時的simsaim就和k-means算法有點類似了。 說法很多 大家可以搜搜看。 

        其實我們可以看出來一點東西,在算loss時, p是預測值, z是標籤,如果標籤也要算梯度,兩邊就都在變了,參考我們平時的label都是不變的,確實z也不應該算梯度。 

        那麼什麼是stop gradiant呢 就是不算梯度的意思。比如 

x = 2
y = 2**2
z = y+x

z.grad = 5


y.detach()
z.grad = 1

 

本來y是x的平方 求導等於4 所以z對x求導是5  然後不算y的梯度了 那麼就只剩1了 。 

 

這就是模型的全部了 ,輸入兩張圖片 ,然後抽特徵  預測 分別算loss 

 

3 訓練過程: 

        訓練是非常普通的訓練。 

        for idx, ((images1, images2), labels) in tqdm(enumerate(local_progress)):

            model.zero_grad()
            data_dict = model.forward(images1.to(device, non_blocking=True), images2.to(device, non_blocking=True))
            loss = data_dict['loss'].mean() # ddp
            loss.backward()
            optimizer.step()
            lr_scheduler.step()
            data_dict.update({'lr':lr_scheduler.get_lr()})
            
            local_progress.set_postfix(data_dict)
            # logger.update_scalers(data_dict)

 從測試集中抽loader  注意抽出的是兩張圖片 由不同transformers形成的。 之後過模型得到loss,梯度回傳。 這裡日誌一直報錯 我直接屏蔽了。  

         

4 測試過程:

        測試過程比較的關鍵。 

這裡是用knn算法進行測試的 ,關於knn 可以看 深入淺出KNN算法(一) KNN算法原理 – zzzzMing – 博客園

 簡單的說, 就是從眾。 在一個大平面上有很多的點, 然後你就看離自己最近的k個點,他們的標籤是啥, 然後選最多的那個當自己的標籤。 

        

            accuracy = knn_monitor(model.module.backbone, memory_loader, test_loader, device, k=min(args.train.knn_k, len(memory_loader.dataset)), hide_progress=args.hide_progress) 

def knn_monitor(net, memory_data_loader, test_data_loader, epoch, k=200, t=0.1, hide_progress=False):
    net.eval()
    classes = len(memory_data_loader.dataset.classes)
    total_top1, total_top5, total_num, feature_bank = 0.0, 0.0, 0, []
    with torch.no_grad():
        # generate feature bank
        for data, target in tqdm(memory_data_loader, desc='Feature extracting', leave=False, disable=hide_progress):
            feature = net(data.cuda(non_blocking=True))
            feature = F.normalize(feature, dim=1)
            feature_bank.append(feature)
        # [D, N]
        feature_bank = torch.cat(feature_bank, dim=0).t().contiguous()
        # [N]
        feature_labels = torch.tensor(memory_data_loader.dataset.targets, device=feature_bank.device)
        # loop test data to predict the label by weighted knn search
        test_bar = tqdm(test_data_loader, desc='kNN', disable=hide_progress)
        for data, target in test_bar:
            data, target = data.cuda(non_blocking=True), target.cuda(non_blocking=True)
            feature = net(data)
            feature = F.normalize(feature, dim=1)
            
            pred_labels = knn_predict(feature, feature_bank, feature_labels, classes, k, t)

            total_num += data.size(0)
            total_top1 += (pred_labels[:, 0] == target).float().sum().item()
            test_bar.set_postfix({'Accuracy':total_top1 / total_num * 100})
    return total_top1 / total_num * 100

注意 這裡的net  只是backbone  也就是resnet 而 memory 就是訓練數據 不過增廣方式不一樣 。還有訓練數據 和k值 取200. 

    net.eval()
    classes = len(memory_data_loader.dataset.classes)
    total_top1, total_top5, total_num, feature_bank = 0.0, 0.0, 0, []

一些初始化和獲取類別數。

        

    with torch.no_grad():
        # generate feature bank
        for data, target in tqdm(memory_data_loader, desc='Feature extracting', leave=False, disable=hide_progress):
            feature = net(data.cuda(non_blocking=True))
            feature = F.normalize(feature, dim=1)
            feature_bank.append(feature)
        # [D, N]
        feature_bank = torch.cat(feature_bank, dim=0).t().contiguous()
        feature_labels = torch.tensor(memory_data_loader.dataset.targets, device=feature_bank.device)

獲取大平面上的點。 從訓練集抽數據, 然後獲取他們的特徵。

最後的feature_bank大小是49664*512 也就是將近50000條數據 每個數據都有512 維的特徵。 然後做了一個轉置。 

        for data, target in test_bar:
            data, target = data.cuda(non_blocking=True), target.cuda(non_blocking=True)
            feature = net(data)
            feature = F.normalize(feature, dim=1)

抽取測試集的特徵。

            pred_labels = knn_predict(feature, feature_bank, feature_labels, classes, k, t)

def knn_predict(feature, feature_bank, feature_labels, classes, knn_k, knn_t):
    # compute cos similarity between each feature vector and feature bank ---> [B, N]
    sim_matrix = torch.mm(feature, feature_bank)
    # [B, K]
    sim_weight, sim_indices = sim_matrix.topk(k=knn_k, dim=-1)        #求出最大的knn_k個值
    # [B, K]
    sim_labels = torch.gather(feature_labels.expand(feature.size(0), -1), dim=-1, index=sim_indices)
    sim_weight = (sim_weight / knn_t).exp()

    # counts for each class
    one_hot_label = torch.zeros(feature.size(0) * knn_k, classes, device=sim_labels.device)
    # [B*K, C]
    one_hot_label = one_hot_label.scatter(dim=-1, index=sim_labels.view(-1, 1), value=1.0)
    # weighted score ---> [B, C]
    pred_scores = torch.sum(one_hot_label.view(feature.size(0), -1, classes) * sim_weight.unsqueeze(dim=-1), dim=1)

    pred_labels = pred_scores.argsort(dim=-1, descending=True)
    return pred_labels

我們來看 knn是如果計算相似度的 ,也就是距離的。torch.mm表示矩陣的乘法。 我舉個例子。 

下面只是例子 ,真實數據需要歸一化

a = [[1,2,3],
     [4,5,6]]
b = [[1,2,3],
     [2,4,6],
     [3,6,9],
     [4,8,1]]

a有2個樣本, b有4個樣本。 他們的特徵都是3維。 現在求a[0]   和b中哪些樣本最相似。 

就要讓a[0]和b中每一個樣本點乘 得到 14, 28, 42, 23。數越大表示越相似,也就越近。 所以我們讓a和b的轉置相乘,得到:

tensor([[14, 28, 42, 23],
        [32, 64, 96, 62]])

我們發現第一排 就是a[0]的相似度, 每一列都是與b中樣本的點乘結果。

    sim_matrix = torch.mm(feature, feature_bank)

所以這裡的sim_matrix 就是一個512 * 49664大小的矩陣。 512 表示有512個樣本, 49664 表示每個樣本和所有點的乘積。 

sim_weight, sim_indices = sim_matrix.topk(k=knn_k, dim=-1) 

topk 表示取最大的值,和他們下標 這裡取200個 我們就得到了離每一個樣本,最近的那些點,他們的下標是多少。

sim_labels = torch.gather(feature_labels.expand(feature.size(0), -1), dim=-1, index=sim_indices)

feature_labels.expand(feature.size(0), -1) 之前的文章說過 ,是一個複製擴充。 -1表示不改變維度。 feature是50000維 擴充後變成512 *50000 (注意label和49664不相等,是因為loader捨棄了最後的一部分,但是沒關係 , 本來就取不到這部分值)。 

torch.gather 是按下標取值。 

我們對標籤按下標取值,得到了512 *200的矩陣, 每一行都表示這個樣本距離最近的200個樣本的標籤。

 

sim_weight = (sim_weight / knn_t).exp()

 看到後面就知道這個knn_t的作用了 。  作用就是 控制相似度的權重。 比如  一個更相似的 他的標籤可以一個頂好幾個不相似的。 那麼頂幾個呢 ? 就是t控制的了 。 

    # counts for each class
    one_hot_label = torch.zeros(feature.size(0) * knn_k, classes, device=sim_labels.device)
    # [B*K, C]
    one_hot_label = one_hot_label.scatter(dim=-1, index=sim_labels.view(-1, 1), value=1.0)

我們需要先搞懂scatter函數 。說實話着實有點難。因為官網的scatter都很難理解了 ,何況這個和官網不一樣 

 我們可以看到 官網的第三個參數是src 也就是數據源,而這裡是value 。。。真是奇怪。 

對於tensor.scatter函數  可以看 這篇

對於torch.tensor.scatter()這個函數的理解。_亮子李的博客-CSDN博客

相信大家對scatter 都有了理解。 我們回來。 

這裡先創建一個 長是512 *200 = 102400 寬是10的向量。

而sim_labels的大小是 (102400,1) 這個scatter做了什麼呢 ? 如下 

one_hot = torch.tensor
   ([[0,0,0,0,0,0,0,0,0,0],
     [0,0,0,0,0,0,0,0,0,0]])

sim_label = torch.tensor([[3],[4]])
print(one_hot.scatter(-1,sim_label,value=1))

tensor([[0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 1, 0, 0, 0, 0, 0]])

也就是把每行標籤對應得數字那一列變為1 ,如果這行特徵得標籤是1 就把第一個數變為1,這樣子。  類似的有102400行。 得到onehot後 按我得想法,  就統計200行中哪一列的1最多唄。 比如前200行里 第3列的1對多, 就說明第一個樣本最近的200個裡,最多的標籤是2 ,。我們看看他們怎麼做的。 

 

pred_scores = torch.sum(one_hot_label.view(feature.size(0), -1, classes) * sim_weight.unsqueeze(dim=-1), dim=1)

one_hot_label.view(feature.size(0), -1, classes) 

這句可以理解。 變回512 *200*10 這樣就可以統計各自的兩百個了。

sim_weight.unsqueeze(dim=-1)

sim_weight 雖然在上面做了一點變換,但是我們其實不用管他,因為上面只是一種歸一化的方式,我們依然可以把它看作最近 當前樣本特徵和兩百個點特徵的乘積。unsqueeze 表示擴充一維 在最後, sim_weight就變成了 512 *200 *1。 我們如何理解這個pred_score呢? 我們不要看512個樣本。 我們只看一個樣本。 對於一個樣本。他的one_label是200*10 而sim_weight就是200 *1  特徵的點乘結果,也就是200個相似度分數 。 從兩行 看兩百行  很顯然 就是讓各行的標籤1 乘上那個相似度分數。  之後再對200這個維度求和,就得到了各個標籤相似的分數的和。 維度1*10

c = one_hot.scatter(-1,sim_label,value=1)
d = torch.tensor([[3],[4]])
print(c*d)



#################
tensor([[0, 0, 0, 1, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 1, 0, 0, 0, 0, 0]])
tensor([[0, 0, 0, 3, 0, 0, 0, 0, 0, 0],
        [0, 0, 0, 0, 4, 0, 0, 0, 0, 0]])


print(torch.sum(c*d,dim=0))



#########
tensor([0, 0, 0, 3, 4, 0, 0, 0, 0, 0])

        看到這裡我們明白了 。 這裡的knn並不是簡單的從眾,他還要看影響力。 更相似的樣本,他的標籤對我們的結果的影響力更大。 這裡相當於對標籤做了一個加權求和。 

回到512維 我們得到了512*10的矩陣 表示512個樣本的各個標籤的相似度分數 我們只要argsort就可以得到最大值的下標啦。 np.argsort這個函數可以對向量排序 然後返回他們原來的下標 des 表示可以降序。 

    pred_labels = pred_scores.argsort(dim=-1, descending=True)
    return pred_labels

得到標籤 , 回到原來的knn

            total_num += data.size(0)
            total_top1 += (pred_labels[:, 0] == target).float().sum().item()
            test_bar.set_postfix({'Accuracy':total_top1 / total_num * 100})
    return total_top1 / total_num * 100

這裡是計算top1  我估計如果計算top5 估計就是 target in labels[:,4]了 得到預測標籤後準確率久很好算了。 

 

5 :線性驗證

 繼續跟着主函數走 。 可以看到一堆保存的步驟。 然後進入linear_eval函數。  我猜測是用backbone抽特徵然後直接預測結果的函數。 

 


    train_loader = torch.utils.data.DataLoader(
        dataset=get_dataset( 
            transform=get_aug(train=False, train_classifier=True, **args.aug_kwargs), 
            train=True, 
            **args.dataset_kwargs
        ),
        batch_size=args.eval.batch_size,
        shuffle=True,
        **args.dataloader_kwargs
    )
    test_loader = torch.utils.data.DataLoader(
        dataset=get_dataset(
            transform=get_aug(train=False, train_classifier=False, **args.aug_kwargs), 
            train=False,
            **args.dataset_kwargs
        ),
        batch_size=args.eval.batch_size,
        shuffle=False,
        **args.dataloader_kwargs
    )


    model = get_backbone(args.model.backbone)
    classifier = nn.Linear(in_features=model.output_dim, out_features=10, bias=True).to(args.device)

 先讀取訓練集和測試集, 然後 model是resnet  一個分類器是 一個全連接。 我好奇的是為什麼不直接把backbone最後一層的恆等映射改為這個分類器呢 ? 

msg = model.load_state_dict({k[9:]:v for k, v in save_dict['state_dict'].items() if k.startswith('backbone.')}, strict=True)

        

載入模型  

 k長這個樣子  取出那些以backb開頭的層 就是resnet的層。 然後去掉前面9個字母 就是resnet的名字。 

    classifier = torch.nn.DataParallel(classifier)
    # define optimizer
    optimizer = get_optimizer(
        args.eval.optimizer.name, classifier, 
        lr=args.eval.base_lr*args.eval.batch_size/256, 
        momentum=args.eval.optimizer.momentum, 
        weight_decay=args.eval.optimizer.weight_decay)

    # define lr scheduler
    lr_scheduler = LR_Scheduler(
        optimizer,
        args.eval.warmup_epochs, args.eval.warmup_lr*args.eval.batch_size/256, 
        args.eval.num_epochs, args.eval.base_lr*args.eval.batch_size/256, args.eval.final_lr*args.eval.batch_size/256, 
        len(train_loader),
    )

    loss_meter = AverageMeter(name='Loss')
    acc_meter = AverageMeter(name='Accuracy')

定義優化器和loss 最下面這個averagemeter是啥呀 

查了一下 就是一個類似於隊列這種的 數據結構。 然後可以更新  關鍵是可以求平均。 

 

    for epoch in global_progress:
        loss_meter.reset()
        model.eval()
        classifier.train()
        local_progress = tqdm(train_loader, desc=f'Epoch {epoch}/{args.eval.num_epochs}', disable=True)
        
        for idx, (images, labels) in enumerate(local_progress):

            classifier.zero_grad()
            with torch.no_grad():
                feature = model(images.to(args.device))

            preds = classifier(feature)

            loss = F.cross_entropy(preds, labels.to(args.device))

            loss.backward()
            optimizer.step()
            loss_meter.update(loss.item())
            lr = lr_scheduler.step()
            local_progress.set_postfix({'lr':lr, "loss":loss_meter.val, 'loss_avg':loss_meter.avg})

然後定義好後 就是一個普通的訓練過程了 。 值得注意的是 model是eval模型 也就是他是凍住的,參數不改變。而classfier是可以改變的,  梯度回傳也只回傳分類頭的梯度, 這裡就只訓練分類器。 


    classifier.eval()
    correct, total = 0, 0
    acc_meter.reset()
    for idx, (images, labels) in enumerate(test_loader):
        with torch.no_grad():
            feature = model(images.to(args.device))
            preds = classifier(feature).argmax(dim=1)
            correct = (preds == labels.to(args.device)).sum().item()
            acc_meter.update(correct/preds.shape[0])
    print(f'Accuracy = {acc_meter.avg*100:.2f}')

普通的測試。

 

6 : 用自己數據集進行對比學習。 

路走遠了,別忘了開始的方向。 我們是用對比學習解決食物分類的問題的。 

  我們要做的有幾件事情。 

第一:  改數據集 :

        

 

把它原來的三個數據集全#了。 

然後 加入自己的數據集。  使用他的增廣方式。 但在增廣前   需要在 dataset的get里加 topil 因為他的增廣里沒有這個。 

hw3食物分類有三個數據集:

一個有標籤訓練集 我用來當memory

一個無標籤訓練集  我用來當train

一個驗證集 我用來測試。 

 

pil_trans = transforms.ToPILImage()

 

    filepath = '/home/lhy/hw3/food-11'
    train_loader = getDataLoader(filepath, 'train_unl', True, args.train.batch_size, transform=get_aug(train=True, train_classifier=False, **args.aug_kwargs))
    memory_loader = getDataLoader(filepath, 'train', False,args.train.batch_size, transform=get_aug(train=False, train_classifier=False, **args.aug_kwargs))
    test_loader = getDataLoader(filepath, 'val', False,args.train.batch_size, transform=get_aug(train=False, train_classifier=False, **args.aug_kwargs))

 

 

 

2 改變batch_size和圖片大小。 

        

 在這個文件里改batch  你會發現這個對比學習的模型 出奇的占內存,當我圖片大小為224時,我的batch只能設置為32. 

 

 main函數里改imagesize 為自己的。 

 

點運行。O了 。 然後發現效果並不是很好。。。。。