Mod of Zach Muller's WWF 01_Custom.ipynb.

Here we'll take cropped images of antinodes and try to count the rings, by fashioning a regression model out of a one-class classification model and scaling the output sigmoid (via fastai's y_range parameter) so that our fitted values stay within the linear regime of the sigmoid.

And we also want to "clamp" our output between a min of about 0.2 rings and a max of 11 rings, because that's how the dataset was created; so sigmoid makes a good choice for this "clamping" too.

 

Installs & Imports

!pip install espiownage --upgrade -q
import espiownage
from espiownage.core import *
sysinfo()
print(f"espiownage version {espiownage.__version__}")
TORCH_VERSION=torch1.9.0; CUDA_VERSION=cu102
CUDA available = True, Device count = 1, Current device = 0
Device name = NVIDIA GeForce RTX 2070 with Max-Q Design
hostname: oryxpro
espiownage version 0.0.47

And import our libraries

from fastai.vision.all import *

Below you will find the exact imports for everything we use today

from fastcore.foundation import L
from fastcore.xtras import Path # @patch'd properties to the Pathlib module

from fastai.callback.fp16 import to_fp16
from fastai.callback.schedule import fit_one_cycle, lr_find 
from fastai.data.external import untar_data, URLs

from fastai.data.block import RegressionBlock, DataBlock
from fastai.data.transforms import get_image_files, Normalize, RandomSplitter, parent_label

from fastai.interpret import ClassificationInterpretation
from fastai.learner import Learner # imports @patch'd properties to Learner including `save`, `load`, `freeze`, and `unfreeze`
from fastai.optimizer import ranger

from fastai.vision.augment import aug_transforms, RandomResizedCrop, Resize, ResizeMethod
from fastai.vision.core import imagenet_stats
from fastai.vision.data import ImageBlock
from fastai.vision.learner import cnn_learner
from fastai.vision.utils import download_images, verify_images

import os

Run parameters

dataset_name = 'fake' # choose from: 
                            # - cleaner (*real* data that's clean-er than "preclean"), 
                            # - preclean (unedited aggregates of 15-or-more volunteers)
                            # - spnet,   (original SPNet Real dataset)
                            # - cyclegan (original SPNet CGSmall dataset)
                            # - fake (newer than SPNet fake, this includes non-int ring #s)
use_wandb = False        # WandB.ai logging
project = 'count_in_crops' # project name for wandb
if use_wandb: 
    !pip install wandb -qqq
    import wandb
    from fastai.callback.wandb import *
    from fastai.callback.tracker import SaveModelCallback
    wandb.login()

Prepare Dataset

path = get_data(dataset_name) / 'crops'; path
---------------------------------------------------------------------------
NameError                                 Traceback (most recent call last)
/tmp/ipykernel_1189450/911103483.py in <module>
      1 #slow
----> 2 path = get_data(dataset_name) / 'crops'; path

NameError: name 'get_data' is not defined
fnames = get_image_files(path)  # image filenames
print(f"{len(fnames)} total cropped images")
ind = 1  # pick one cropped image
fnames[ind]
6923 total cropped images
Path('/home/drscotthawley/.espiownage/data/espiownage-fake/crops/steelpan_0001825_56_60_235_129_4.4.png')

For labels, we want the ring count which extract from the filename: It's the number between the last '_' and the '.png'

def label_func(x):  
    return round(float(x.stem.split('_')[-1]),2)

print(label_func(fnames[ind]))
4.4
cropsize = (300,300) # we will resize/reshape all input images to squares of this size
croppedrings = DataBlock(blocks=(ImageBlock, RegressionBlock(n_out=1)),
                    get_items=get_image_files,
                    splitter=RandomSplitter(),  # Note the random splitting. K-fold is another notebook
                    get_y=label_func,
                    item_tfms=Resize(cropsize, ResizeMethod.Squish),
                    batch_tfms=[*aug_transforms(size=cropsize, flip_vert=True, max_rotate=360.0), 
                    Normalize.from_stats(*imagenet_stats)])
# define dataloaders
dls = croppedrings.dataloaders(path, bs=32)
/home/drscotthawley/envs/espi/lib/python3.9/site-packages/torch/_tensor.py:1023: UserWarning: torch.solve is deprecated in favor of torch.linalg.solveand will be removed in a future PyTorch release.
torch.linalg.solve has its arguments reversed and does not return the LU factorization.
To get the LU factorization see torch.lu, which can be used with torch.lu_solve or torch.lu_unpack.
X = torch.solve(B, A).solution
should be replaced with
X = torch.linalg.solve(A, B) (Triggered internally at  /pytorch/aten/src/ATen/native/BatchLinearAlgebra.cpp:760.)
  ret = func(*args, **kwargs)

Take a look at sample target data. Notice how they're very circular! That's how we 'got away with' arbitrary (360 degree) rotations in the DataBlock's batch_tfms, above^.

dls.show_batch(max_n=9)

Train model

opt = ranger # optimizer the kids love these days

y_range=(0.2,13)  # balance between "clamping" to range of real data vs too much "compression" from sigmoid nonlinearity

if use_wandb:
    wandb.init(project=project, name=f'{dataset_name}')
    cbs = [WandbCallback()]
else:
    cbs = []

learn = cnn_learner(dls, resnet34, n_out=1, y_range=y_range, 
                    metrics=[mae, acc_reg05, acc_reg07, acc_reg1,acc_reg15,acc_reg2], 
                    loss_func=MSELossFlat(), opt_func=opt, cbs=cbs)
/home/drscotthawley/envs/espi/lib/python3.9/site-packages/torch/nn/functional.py:718: UserWarning: Named tensors and all their associated APIs are an experimental feature and subject to change. Please do not use them for anything important until they are released as stable. (Triggered internally at  /pytorch/c10/core/TensorImpl.h:1156.)
  return torch.max_pool2d(input, kernel_size, stride, padding, dilation, ceil_mode)
learn.lr_find() # we're just going to use 5e-3 though
/home/drscotthawley/envs/espi/lib/python3.9/site-packages/fastai/callback/schedule.py:269: UserWarning: color is redundantly defined by the 'color' keyword argument and the fmt string "ro" (-> color='r'). The keyword argument will take precedence.
  ax.plot(val, idx, 'ro', label=nm, c=color)
SuggestedLRs(valley=0.007585775572806597)
lr = 5e-3
epochs = 30 # 10-11 epochs is fine for lr=1e-2; here we do 30 w/ lower lr to see if we can "do better"
learn.fine_tune(epochs, lr, freeze_epochs=2)  
epoch train_loss valid_loss mae acc_reg05 acc_reg07 acc_reg1 acc_reg15 acc_reg2 time
0 13.357956 7.607709 2.238656 0.133671 0.173410 0.272399 0.395231 0.523844 00:39
1 6.488315 3.025391 1.333365 0.244220 0.351879 0.474711 0.655347 0.774566 00:39
epoch train_loss valid_loss mae acc_reg05 acc_reg07 acc_reg1 acc_reg15 acc_reg2 time
0 2.550838 1.060201 0.741373 0.450867 0.586705 0.736272 0.899566 0.952312 00:52
1 1.529831 0.627518 0.543706 0.584538 0.732659 0.879335 0.960260 0.980491 00:52
2 1.069771 0.577048 0.511525 0.606936 0.763006 0.895231 0.972543 0.984827 00:52
3 0.776823 0.404927 0.387394 0.743497 0.863439 0.954480 0.986272 0.989884 00:52
4 0.838223 0.445949 0.440269 0.681358 0.813584 0.911850 0.971821 0.991329 00:52
5 0.655202 0.323646 0.408258 0.697977 0.856936 0.959538 0.992052 0.995665 00:52
6 0.671638 0.706502 0.603800 0.541185 0.682803 0.821532 0.927746 0.976156 00:52
7 0.630134 0.425947 0.425484 0.711705 0.857659 0.932081 0.979769 0.988439 00:52
8 0.592590 0.347298 0.416546 0.730491 0.854046 0.935694 0.986994 0.994220 00:52
9 0.595489 0.281299 0.344424 0.776734 0.900289 0.973266 0.992775 0.994942 00:52
10 0.555089 0.227254 0.325332 0.828757 0.935694 0.977601 0.992052 0.996387 00:52
11 0.541050 0.189151 0.308729 0.857659 0.942197 0.982659 0.994942 0.998555 00:52
12 0.657278 0.261319 0.317320 0.836705 0.905347 0.956647 0.983382 0.996387 00:52
13 0.489212 0.147704 0.257262 0.882225 0.938584 0.983382 0.996387 0.997832 00:53
14 0.438599 0.205918 0.266294 0.895954 0.951590 0.979046 0.993497 0.997832 00:52
15 0.440577 0.347534 0.411550 0.700145 0.813584 0.929191 0.991329 0.998555 00:52
16 0.513884 0.197660 0.298510 0.842486 0.929913 0.962428 0.991329 0.999277 00:52
17 0.505132 0.154544 0.228440 0.916185 0.954480 0.984104 0.997832 0.998555 00:52
18 0.464592 0.186471 0.265210 0.891618 0.951590 0.978324 0.998555 0.998555 00:52
19 0.400126 0.138628 0.206167 0.928468 0.960260 0.988439 0.996387 0.998555 00:52
20 0.305081 0.187768 0.298606 0.850434 0.948699 0.988439 0.998555 0.998555 00:52
21 0.448412 0.184450 0.251873 0.890173 0.945809 0.978324 0.997832 0.997832 00:52
22 0.276809 0.154588 0.234391 0.929191 0.966763 0.992052 0.998555 0.998555 00:52
23 0.272330 0.168835 0.244713 0.880058 0.950145 0.984104 0.998555 0.998555 00:52
24 0.290513 0.159558 0.245472 0.911850 0.961705 0.986994 0.998555 0.998555 00:52
25 0.270679 0.181158 0.268713 0.885838 0.947977 0.981936 0.998555 0.998555 00:52
26 0.247273 0.148759 0.234591 0.913295 0.973988 0.993497 0.998555 0.998555 00:52
27 0.242083 0.138362 0.226965 0.930636 0.973988 0.994220 0.998555 0.998555 00:52
28 0.206810 0.134484 0.218145 0.930636 0.975434 0.993497 0.998555 0.998555 00:52
29 0.247790 0.134242 0.214004 0.936416 0.976156 0.995665 0.998555 0.998555 00:52

^ we could go back up and cut this off at 10, 15 or 20 epochs. In this case I just wanted to explore how low the val_loss would go!

if use_wandb: wandb.finish()
learn.save(f'crop-rings-{dataset_name}') # save a checkpoint so we can restart from here later
Path('models/crop-rings-fake.pth')

Interpretation

learn.load(f'crop-rings-{dataset_name}'); # can start from here assuming learn, dls, etc are defined
preds, targs, losses = learn.get_preds(with_loss=True) # validation set only
print(f"We have {len(preds)} predictions.")
We have 1384 predictions.

Let's define a method to show a single prediction

def showpred(ind, preds, targs, losses, dls): # show prediction at this index
    print(f"preds[{ind}] = {preds[ind]}, targs[{ind}] = {targs[ind]}, loss = {losses[ind]}")
    print(f"file = {os.path.basename(dls.valid.items[ind])}")
    print("Image:")
    dls.valid.dataset[ind][0].show()
showpred(0, preds, targs, losses, dls)
preds[0] = tensor([8.4272]), targs[0] = 8.800000190734863, loss = 0.13895699381828308
file = steelpan_0000621_74_116_145_189_8.8.png
Image:

And now we'll run through predictions for the whole validation set:

results = []
for i in range(len(preds)):
    line_list = [dls.valid.items[i].stem]+[round(targs[i].cpu().numpy().item(),2), round(preds[i][0].cpu().numpy().item(),2), losses[i].cpu().numpy(), i]
    results.append(line_list)

# store ring counts as as Pandas dataframe
res_df = pd.DataFrame(results, columns=['filename', 'target', 'prediction', 'loss','i'])

There is no fastai top_losses defined for this type, but we can do our own version of printing top_losses:

res_df = res_df.sort_values('loss', ascending=False)
res_df.head()
filename target prediction loss i
91 steelpan_0001247_105_200_246_305_9.8 9.8 1.48 69.16974 91
418 steelpan_0000006_305_221_352_272_0.5 0.5 4.42 15.373232 418
1007 steelpan_0000700_311_0_410_184_1.4 1.4 0.20 1.4342293 1007
679 steelpan_0000438_107_225_168_384_1.4 1.4 0.23 1.3687538 679
607 steelpan_0001704_328_223_411_306_1.3 1.3 0.20 1.202372 607
def show_top_losses(res_df, preds, targs, losses, dls, n=5):
    for j in range(n):
        showpred(res_df.iloc[j]['i'], preds, targs, losses, dls)
        
show_top_losses(res_df, preds, targs, losses, dls)
preds[91] = tensor([1.4832]), targs[91] = 9.800000190734863, loss = 69.16973876953125
file = steelpan_0001247_105_200_246_305_9.8.png
Image:
preds[418] = tensor([4.4209]), targs[418] = 0.5, loss = 15.373231887817383
file = steelpan_0000006_305_221_352_272_0.5.png
Image:
preds[1007] = tensor([0.2024]), targs[1007] = 1.399999976158142, loss = 1.4342292547225952
file = steelpan_0000700_311_0_410_184_1.4.png
Image:
preds[679] = tensor([0.2301]), targs[679] = 1.399999976158142, loss = 1.3687537908554077
file = steelpan_0000438_107_225_168_384_1.4.png
Image:
preds[607] = tensor([0.2035]), targs[607] = 1.2999999523162842, loss = 1.2023719549179077
file = steelpan_0001704_328_223_411_306_1.3.png
Image:

So then we can these results output into a CSV file, and use it to direct our data-cleaning efforts, i.e. look at the top-loss images first!

res_df.to_csv(f'ring_count_top_losses_{dataset_name}.csv', index=False)

Explore the Data

Let's take a look at plots of this data

df2 = res_df.reset_index(drop=True)
plt.plot(df2["target"],'o',label='target')
plt.plot(df2["prediction"],'s', label='prediction')
plt.xlabel('Top-loss order (left=worse, right=better)')
plt.legend(loc='lower right')
plt.ylabel('Ring count')
Text(0, 0.5, 'Ring count')
plt.plot(df2["target"],df2["prediction"],'o')
plt.xlabel('Target ring count')
plt.ylabel('Predicted ring count')
plt.axis('square')
(-0.025000000000000022, 11.767, -0.336, 11.456)
print(f"Target ring count range: ({df2['target'].min()}, {df2['target'].max()})")
print(f"Predicted ring count range: ({df2['prediction'].min()}, {df2['prediction'].max()})")
Target ring count range: (0.5, 11.0)
Predicted ring count range: (0.2, 10.92)